diff --git a/CHANGELOG.md b/CHANGELOG.md index 735becb..01bb6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The changelog format is based on [Keep a Changelog](https://keepachangelog.com/e ## [Unreleased] ### Changed +* Moved CMake + conan + c++ package files and folders with cpp code inside src folder to be included in package +* Replace pkg_resources with importlib.metadata for getting packages version to work in python 3.12 +* Replace deprecated root_validator with model_validator in pydanitc class +* Remove unnecessary hard coded values in utils/builder.py +* Add MlFmuBuilder class to generate code and compile FMU + * Find default paths to files and directories if not given in cli + * Run functions in utils/builder.py according to which command is being run + * Clean up temporary/build files after the process is done +* Complete cli interface + * Add subparsers to cli argparser to handle several different commands + * Create MlFmuBuilder from args and run code according to command +* Change cli test to match the new cli interface/parser + +### Changed + * Default agent_(input/output)_indexes is [] by default instead of None * Updated doc by running publish-interface-docs * Added feature to be able to initialize states using previously defined parameters or inputs diff --git a/docs/interface/schema.html b/docs/interface/schema.html index ce7bee9..0cd00e0 100644 --- a/docs/interface/schema.html +++ b/docs/interface/schema.html @@ -63,4 +63,4 @@

Description

Default: null

Short FMU variable description.

Type: string
Type: null

Startvalue

Default: null

The default value of the parameter used for initialization. If this field is set parameters for initialization will be automatically generated for these states.

Type: number
Type: null

Initializationvariable

Default: null

The name of a an input or parameter in the same model interface that should be used to initialize this state.

Type: string
Type: null

Agentoutputindexes

Type: array of string Default: []

Index or range of indices of agent outputs that will be stored as internal states and will be fed as inputs in the next time step. Note: the FMU signal and the agent outputs need to have the same length.

No Additional Items

Each item of this array must be:

Type: string
Must match regular expression: ^(\d+|\d+:\d+)$

Examples:

"10"
 
"10:20"
 
"30"
-

Usestime

Default: false

Whether the agent consumes time data from co-simulation algorithm.

Type: boolean
Type: null

Stateinitializationreuse

Type: boolean Default: false

Whether variables are allowed to be reused for state initialization when initializationvariable is used for state initialization. If set to true the variable referred to in initializationvariable will be repeated for the state initialization until the entire state is initialized.

\ No newline at end of file +

Usestime

Default: false

Whether the agent consumes time data from co-simulation algorithm.

Type: boolean
Type: null

Stateinitializationreuse

Type: boolean Default: false

Whether variables are allowed to be reused for state initialization when initializationvariable is used for state initialization. If set to true the variable referred to in initializationvariable will be repeated for the state initialization until the entire state is initialized.

\ No newline at end of file diff --git a/src/mlfmu/api.py b/src/mlfmu/api.py index efc7c4a..063f46c 100644 --- a/src/mlfmu/api.py +++ b/src/mlfmu/api.py @@ -1,18 +1,37 @@ import logging import os +import shutil +import tempfile +from enum import Enum from pathlib import Path -from typing import Union +from typing import List, Optional -from dictIO import DictReader +import mlfmu.utils.builder as builder __ALL__ = ["run", "MlFmuProcess"] logger = logging.getLogger(__name__) +class MlFmuCommand(Enum): + BUILD = "build" + GENERATE = "codegen" + COMPILE = "compile" + + @staticmethod + def from_string(command_string: str): + matches = [command for command in MlFmuCommand if command.value == command_string] + if len(matches) == 0: + return None + return matches[0] + + def run( - config_file: Union[str, os.PathLike[str]], - option: bool = False, + command: MlFmuCommand, + interface_file: Optional[str], + model_file: Optional[str], + fmu_path: Optional[str], + source_folder: Optional[str], ): """Run the mlfmu process. @@ -20,47 +39,332 @@ def run( Parameters ---------- - config_file : Union[str, os.PathLike[str]] - file containing the mlfmu configuration - option : bool, optional - if True, does something differently, by default False - - Raises - ------ - FileNotFoundError - if config_file does not exist + command: MlFmuCommand + which command in the mlfmu process that should be run + interface_file: Optional[str] + the path to the file containing the FMU interface. Will be inferred if not provided. + model_file: Optional[str] + the path to the ml model file. Will be inferred if not provided. + fmu_path: Optional[str] + the path to where the built FMU should be saved. Will be inferred if not provided. + source_folder: Optional[str] + the path to where the FMU source code is located. Will be inferred if not provided. """ - # Make sure config_file argument is of type Path. If not, cast it to Path type. - config_file = config_file if isinstance(config_file, Path) else Path(config_file) + process = MlFmuProcess( + command=command, + source_folder=Path(source_folder) if source_folder is not None else None, + interface_file=Path(interface_file) if interface_file is not None else None, + ml_model_file=Path(model_file) if model_file is not None else None, + fmu_output_folder=Path(fmu_path) if fmu_path is not None else None, + ) + process.run() + + return - # Check whether config file exists - if not config_file.exists(): - logger.error(f"run: File {config_file} not found.") - raise FileNotFoundError(config_file) - if option: - logger.info("option is True. mlfmu process will do something differently.") +class MlFmuBuilder: + """Class for executing the different commands in the mlfmu process.""" - process = MlFmuProcess(config_file) - process.run() + fmu_name: Optional[str] = None + build_folder: Optional[Path] = None + source_folder: Optional[Path] = None + ml_model_file: Optional[Path] = None + interface_file: Optional[Path] = None + fmu_output_folder: Optional[Path] = None + delete_build_folders: bool = False + temp_folder: Optional[tempfile.TemporaryDirectory[str]] = None + root_directory: Path - return + def __init__( + self, + fmu_name: Optional[str] = None, + interface_file: Optional[Path] = None, + ml_model_file: Optional[Path] = None, + source_folder: Optional[Path] = None, + fmu_output_folder: Optional[Path] = None, + build_folder: Optional[Path] = None, + delete_build_folders: bool = False, + root_directory: Optional[Path] = None, + ): + self.fmu_name = fmu_name + self.interface_file = interface_file + self.ml_model_file = ml_model_file + self.source_folder = source_folder + self.fmu_output_folder = fmu_output_folder + self.build_folder = build_folder + self.delete_build_folders = delete_build_folders + self.root_directory = root_directory or Path(os.getcwd()) + + def build(self): + """ + Build an FMU from ml_model_file and interface_file and saves it to fmu_output_folder. + + If the paths to the necessary files and directories are not given the function will try to find files and directories that match the ones needed. + + Raises + ------ + FileNotFoundError + if ml_model_file or interface_file do not exists or is not set and cannot be easily inferred. + --- + """ + self.source_folder = self.source_folder or self.default_build_source_folder() + + self.ml_model_file = self.ml_model_file or self.default_model_file() + if self.ml_model_file is None: + raise FileNotFoundError( + "No model file was provided and no obvious model file found in current working directory (os.getcwd())" + ) + if not self.ml_model_file.exists(): + raise FileNotFoundError(f"The given model file (={self.ml_model_file}) does not exist.") + + self.interface_file = self.interface_file or self.default_interface_file() + if self.interface_file is None: + raise FileNotFoundError( + "No interface json file was provided and no obvious interface file found in current working directory (os.getcwd())" + ) + if not self.interface_file.exists(): + raise FileNotFoundError(f"The given interface json file (={self.interface_file}) does not exist.") + + self.build_folder = self.build_folder or self.default_build_folder() + + self.fmu_output_folder = self.fmu_output_folder or self.default_fmu_output_folder() + + try: + fmi_model = builder.generate_fmu_files(self.source_folder, self.ml_model_file, self.interface_file) + except Exception as e: + print(e) + if self.delete_build_folders: + self.delete_source() + return + + self.fmu_name = fmi_model.name + builder.build_fmu( + fmu_src_path=self.source_folder / self.fmu_name, + fmu_build_path=self.build_folder, + fmu_save_path=self.fmu_output_folder, + ) + + if self.delete_build_folders: + self.delete_source() + self.delete_build() + self.delete_temp_folder() + + def generate(self): + """ + Generate FMU c++ source code and model description from ml_model_file and interface_file and saves it to source_folder. + + If the paths to the necessary files and directories are not given the function will try to find files and directories that match the ones needed. + + Raises + ------ + FileNotFoundError + if ml_model_file or interface_file do not exists or is not set and cannot be easily inferred. + """ + self.source_folder = self.source_folder or self.default_generate_source_folder() + + self.ml_model_file = self.ml_model_file or self.default_model_file() + if self.ml_model_file is None: + raise FileNotFoundError( + "No model file was provided and no obvious model file found in current working directory (os.getcwd())" + ) + if not self.ml_model_file.exists(): + raise FileNotFoundError(f"The given model file (={self.ml_model_file}) does not exist.") + + self.interface_file = self.interface_file or self.default_interface_file() + if self.interface_file is None: + raise FileNotFoundError( + "No interface json file was provided and no obvious interface file found in current working directory (os.getcwd())" + ) + if not self.interface_file.exists(): + raise FileNotFoundError(f"The given interface json file (={self.interface_file}) does not exist.") + + try: + fmi_model = builder.generate_fmu_files(self.source_folder, self.ml_model_file, self.interface_file) + self.fmu_name = fmi_model.name + except Exception as e: + print(e) + if self.delete_build_folders: + self.delete_source() + + def compile(self): + """ + Compile FMU from FMU c++ source code and model description contained in source_folder and saves it to fmu_output_folder. + + If the paths to the necessary files and directories are not given the function will try to find files and directories that match the ones needed. + + Raises + ------ + FileNotFoundError + if source_folder or fmu_name is not set and cannot be easily inferred. + """ + self.build_folder = self.build_folder or self.default_build_folder() + + self.fmu_output_folder = self.fmu_output_folder or self.default_fmu_output_folder() + + if self.fmu_name is None or self.source_folder is None: + source_child_folder = self.default_compile_source_folder() + if source_child_folder is None: + raise FileNotFoundError( + f"No valid FMU source directory found anywhere inside the current working directory or any given source path (={self.source_folder})." + ) + self.fmu_name = source_child_folder.stem + self.source_folder = source_child_folder.parent + + try: + builder.build_fmu( + fmu_src_path=self.source_folder / self.fmu_name, + fmu_build_path=self.build_folder, + fmu_save_path=self.fmu_output_folder, + ) + except Exception as e: + print(e) + + if self.delete_build_folders: + self.delete_build() + self.delete_temp_folder() + + def delete_source(self): + """Delete the source folder if it exists.""" + if self.source_folder is not None and self.source_folder.exists(): + shutil.rmtree(self.source_folder) + + def delete_build(self): + """Delete the build folder if it exists.""" + if self.build_folder is not None and self.build_folder.exists(): + shutil.rmtree(self.build_folder) + + def delete_temp_folder(self): + """Delete the temp folder if it exists.""" + if self.temp_folder is not None and Path(self.temp_folder.name).exists(): + shutil.rmtree(Path(self.temp_folder.name)) + + def default_interface_file(self): + """Return the path to a interface json file inside self.root_directory if it can be inferred.""" + return MlFmuBuilder._find_default_file(self.root_directory, "json", "interface") + + def default_model_file(self): + """Return the path to a ml model file inside self.root_directory if it can be inferred.""" + return MlFmuBuilder._find_default_file(self.root_directory, "onnx", "model") + + def default_build_folder(self): + """Return the path to a build folder inside the temp_folder. Creates the temp_folder if it is not set.""" + self.temp_folder = self.temp_folder or tempfile.TemporaryDirectory(prefix="mlfmu_") + return Path(self.temp_folder.name) / "build" + + def default_build_source_folder(self): + """Return the path to a src folder inside the temp_folder. Creates the temp_folder if it is not set.""" + self.temp_folder = self.temp_folder or tempfile.TemporaryDirectory(prefix="mlfmu_") + return Path(self.temp_folder.name) / "src" + + def default_generate_source_folder(self): + """Return the path to the default source folder for the generate process.""" + return self.root_directory + + def default_compile_source_folder(self): + """Return the path to the default source folder for the compile process. + + Searches inside self.source_folder and self.root_directory for a folder that contains a folder structure and files that is required to be valid ml fmu source code. + """ + search_folders: List[Path] = [] + if self.source_folder is not None: + search_folders.append(self.source_folder) + search_folders.append(self.root_directory) + source_folder: Optional[Path] = None + # If source folder is not provide try to find one in current folder that is compatible with the tool + # I.e a folder that contains everything needed for compilation + for current_folder in search_folders: + for dir, _, _ in os.walk(current_folder): + try: + possible_source_folder = Path(dir) + # If a fmu name is given and the candidate folder name does not match. Skip it! + if self.fmu_name is not None and possible_source_folder.stem != self.fmu_name: + continue + builder.validate_fmu_source_files(possible_source_folder) + source_folder = possible_source_folder + # If a match was found stop searching + break + except Exception: + # Any folder that does not contain the correct folder structure and files needed for compilation will raise and exception + continue + # If a match was found stop searching + if source_folder is not None: + break + return source_folder + + def default_fmu_output_folder(self): + """Return the path to the default fmu output folder.""" + return self.root_directory + + @staticmethod + def _find_default_file(dir: Path, file_extension: str, default_name: Optional[str] = None): + """Return a file inside dir with the file extension that matches file_extension. If there are multiple matches it uses the closest match to default_name if given. Return None if there is no clear match.""" + # Check if there is a file with correct file extension in current working directory. If it exists use it. + matching_files: List[Path] = [] + + for file in os.listdir(dir): + file_path = dir / file + if file_path.is_file() and file_path.suffix.lstrip(".") == file_extension: + matching_files.append(file_path) + + if len(matching_files) == 0: + return + + if len(matching_files) == 1: + return matching_files[0] + + # If there are more matches on file extension. Use the one that matches the default name + if default_name is None: + return + + name_matches = [file for file in matching_files if default_name in file.stem] + + if len(name_matches) == 0: + return + + if len(name_matches) == 1: + return name_matches[0] + + # If more multiple name matches use the exact match if it exists + name_exact_matches = [file for file in matching_files if default_name == file.stem] + + if len(name_exact_matches) == 1: + return name_matches[0] + return class MlFmuProcess: """Top level class encapsulating the mlfmu process.""" + command: MlFmuCommand + builder: MlFmuBuilder + def __init__( self, - config_file: Path, + command: MlFmuCommand, + source_folder: Optional[Path] = None, + ml_model_file: Optional[Path] = None, + interface_file: Optional[Path] = None, + fmu_output_folder: Optional[Path] = None, ): - self.config_file: Path = config_file self._run_number: int = 0 self._max_number_of_runs: int = 1 self.terminate: bool = False - self._read_config_file() - return + + self.command = command + + fmu_name: Optional[str] = None + build_folder: Optional[Path] = None + + self.builder = MlFmuBuilder( + fmu_name, + interface_file, + ml_model_file, + source_folder, + fmu_output_folder, + build_folder, + delete_build_folders=True, + ) def run(self): """Run the mlfmu process. @@ -114,30 +418,10 @@ def _run_process(self): logger.info(f"Successfully finished run {self._run_number}") + if self.command == MlFmuCommand.BUILD: + self.builder.build() + elif self.command == MlFmuCommand.GENERATE: + self.builder.generate() + elif self.command == MlFmuCommand.COMPILE: + self.builder.compile() return - - def _read_config_file(self): - """Read config file.""" - config = DictReader.read(self.config_file) - if "max_number_of_runs" in config: - self._max_number_of_runs = config["max_number_of_runs"] - return - - -def _do_cool_stuff(run_number: int) -> str: - """Do cool stuff. - - Converts the passed in run number to a string. - - Parameters - ---------- - run_number : int - the run number - - Returns - ------- - str - the run number converted to string - """ - result: str = "" - return result diff --git a/src/mlfmu/cli/mlfmu.py b/src/mlfmu/cli/mlfmu.py index 12fae85..5a1d640 100644 --- a/src/mlfmu/cli/mlfmu.py +++ b/src/mlfmu/cli/mlfmu.py @@ -4,9 +4,9 @@ import argparse import logging from pathlib import Path -from typing import Union +from typing import Optional, Union -from mlfmu.api import run +from mlfmu.api import MlFmuCommand, run from mlfmu.utils.logger import configure_logging logger = logging.getLogger(__name__) @@ -15,29 +15,14 @@ def _argparser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="mlfmu", - usage="%(prog)s config_file [options [args]]", epilog="_________________mlfmu___________________", prefix_chars="-", add_help=True, - description=("mlfmu config_file --option"), ) - _ = parser.add_argument( - "config_file", - metavar="config_file", - type=str, - help="name of the file containing the mlfmu configuration.", - ) - - _ = parser.add_argument( - "--option", - action="store_true", - help="example option.", - default=False, - required=False, - ) + common_args_parser = argparse.ArgumentParser(add_help=False) - console_verbosity = parser.add_mutually_exclusive_group(required=False) + console_verbosity = common_args_parser.add_mutually_exclusive_group(required=False) _ = console_verbosity.add_argument( "-q", @@ -55,7 +40,7 @@ def _argparser() -> argparse.ArgumentParser: default=False, ) - _ = parser.add_argument( + _ = common_args_parser.add_argument( "--log", action="store", type=str, @@ -64,7 +49,7 @@ def _argparser() -> argparse.ArgumentParser: required=False, ) - _ = parser.add_argument( + _ = common_args_parser.add_argument( "--log-level", action="store", type=str, @@ -74,6 +59,57 @@ def _argparser() -> argparse.ArgumentParser: required=False, ) + # Create a sub parser for each command + sub_parsers = parser.add_subparsers(dest="command", title="Available commands", metavar="command", required=True) + + # Main command + # build command to go from config to compiled fmu + build_parser = sub_parsers.add_parser( + MlFmuCommand.BUILD.value, + help="Build FMU from interface and model files", + parents=[common_args_parser], + add_help=True, + ) + + # Add options for build command + _ = build_parser.add_argument("--interface-file", type=str, help="JSON file describing the FMU following schema") + _ = build_parser.add_argument("--model-file", type=str, help="ONNX file containing the ML Model") + _ = build_parser.add_argument("--fmu-path", type=str, help="Path to where the built FMU should be saved") + + # Split the main build command into steps for customization + # generate-code command to go from config to generated fmu source code + code_generation_parser = sub_parsers.add_parser( + MlFmuCommand.GENERATE.value, + help="Generate FMU source code from interface and model files", + parents=[common_args_parser], + add_help=True, + ) + + # Add options for code generation command + _ = code_generation_parser.add_argument( + "--interface-file", type=str, help="json file describing the FMU following schema" + ) + _ = code_generation_parser.add_argument("--model-file", type=str, help="onnx file containing the ML Model") + _ = code_generation_parser.add_argument( + "--fmu-source-path", + help="Path to where the generated FMU source code should be saved. Given path/to/folder the files can be found in path/to/folder/[FmuName]", + ) + + # build-code command to go from fmu source code to compiled fmu + build_code_parser = sub_parsers.add_parser( + MlFmuCommand.COMPILE.value, help="Build FMU from FMU source code", parents=[common_args_parser], add_help=True + ) + + # Add option for fmu compilation + _ = build_code_parser.add_argument( + "--fmu-source-path", + type=str, + help="Path to the folder where the FMU source code is located. The folder needs to have the same name as the FMU. E.g. path/to/folder/[FmuName]", + ) + _ = build_code_parser.add_argument( + "--fmu-path", type=str, help="Path to where the where the built FMU should be saved" + ) + return parser @@ -97,24 +133,25 @@ def main(): log_level_file: str = args.log_level configure_logging(log_level_console, log_file, log_level_file) - config_file: Path = Path(args.config_file) - option: bool = args.option + command: Optional[MlFmuCommand] = MlFmuCommand.from_string(args.command) - # Check whether mlfmu config file exists - if not config_file.is_file(): - logger.error(f"mlfmu.py: File {config_file} not found.") - return + if command is None: + raise ValueError( + f"The given command (={args.command}) does not match any of the existing commands (={[command.value for command in MlFmuCommand]})." + ) - logger.info( - f"Start mlfmu.py with following arguments:\n" - f"\t config_file: \t{config_file}\n" - f"\t option: \t\t\t{option}\n" - ) + interface_file = args.interface_file if "interface_file" in args else None + model_file = args.model_file if "model_file" in args else None + fmu_path = args.fmu_path if "fmu_path" in args else None + source_folder = args.fmu_source_path if "fmu_source_path" in args else None # Invoke API run( - config_file=config_file, - option=option, + command=command, + interface_file=interface_file, + model_file=model_file, + fmu_path=fmu_path, + source_folder=source_folder, ) diff --git a/CMakeLists.txt b/src/mlfmu/fmu_build/CMakeLists.txt similarity index 100% rename from CMakeLists.txt rename to src/mlfmu/fmu_build/CMakeLists.txt diff --git a/cmake/FindFMUComplianceChecker.cmake b/src/mlfmu/fmu_build/cmake/FindFMUComplianceChecker.cmake similarity index 100% rename from cmake/FindFMUComplianceChecker.cmake rename to src/mlfmu/fmu_build/cmake/FindFMUComplianceChecker.cmake diff --git a/cmake/GenerateFmuGuid.cmake b/src/mlfmu/fmu_build/cmake/GenerateFmuGuid.cmake similarity index 100% rename from cmake/GenerateFmuGuid.cmake rename to src/mlfmu/fmu_build/cmake/GenerateFmuGuid.cmake diff --git a/cmake/ZipAll.cmake b/src/mlfmu/fmu_build/cmake/ZipAll.cmake similarity index 100% rename from cmake/ZipAll.cmake rename to src/mlfmu/fmu_build/cmake/ZipAll.cmake diff --git a/cmake/fmu-uuid.h.in b/src/mlfmu/fmu_build/cmake/fmu-uuid.h.in similarity index 100% rename from cmake/fmu-uuid.h.in rename to src/mlfmu/fmu_build/cmake/fmu-uuid.h.in diff --git a/conanfile.txt b/src/mlfmu/fmu_build/conanfile.txt similarity index 100% rename from conanfile.txt rename to src/mlfmu/fmu_build/conanfile.txt diff --git a/cppfmu/.editorconfig b/src/mlfmu/fmu_build/cppfmu/.editorconfig similarity index 100% rename from cppfmu/.editorconfig rename to src/mlfmu/fmu_build/cppfmu/.editorconfig diff --git a/cppfmu/.gitignore b/src/mlfmu/fmu_build/cppfmu/.gitignore similarity index 100% rename from cppfmu/.gitignore rename to src/mlfmu/fmu_build/cppfmu/.gitignore diff --git a/cppfmu/LICENCE.txt b/src/mlfmu/fmu_build/cppfmu/LICENCE.txt similarity index 100% rename from cppfmu/LICENCE.txt rename to src/mlfmu/fmu_build/cppfmu/LICENCE.txt diff --git a/cppfmu/README.md b/src/mlfmu/fmu_build/cppfmu/README.md similarity index 100% rename from cppfmu/README.md rename to src/mlfmu/fmu_build/cppfmu/README.md diff --git a/cppfmu/cppfmu_common.hpp b/src/mlfmu/fmu_build/cppfmu/cppfmu_common.hpp similarity index 100% rename from cppfmu/cppfmu_common.hpp rename to src/mlfmu/fmu_build/cppfmu/cppfmu_common.hpp diff --git a/cppfmu/cppfmu_cs.cpp b/src/mlfmu/fmu_build/cppfmu/cppfmu_cs.cpp similarity index 100% rename from cppfmu/cppfmu_cs.cpp rename to src/mlfmu/fmu_build/cppfmu/cppfmu_cs.cpp diff --git a/cppfmu/cppfmu_cs.hpp b/src/mlfmu/fmu_build/cppfmu/cppfmu_cs.hpp similarity index 100% rename from cppfmu/cppfmu_cs.hpp rename to src/mlfmu/fmu_build/cppfmu/cppfmu_cs.hpp diff --git a/cppfmu/fmi_functions.cpp b/src/mlfmu/fmu_build/cppfmu/fmi_functions.cpp similarity index 100% rename from cppfmu/fmi_functions.cpp rename to src/mlfmu/fmu_build/cppfmu/fmi_functions.cpp diff --git a/fmi/fmi2FunctionTypes.h b/src/mlfmu/fmu_build/fmi/fmi2FunctionTypes.h similarity index 100% rename from fmi/fmi2FunctionTypes.h rename to src/mlfmu/fmu_build/fmi/fmi2FunctionTypes.h diff --git a/fmi/fmi2Functions.h b/src/mlfmu/fmu_build/fmi/fmi2Functions.h similarity index 100% rename from fmi/fmi2Functions.h rename to src/mlfmu/fmu_build/fmi/fmi2Functions.h diff --git a/fmi/fmi2TypesPlatform.h b/src/mlfmu/fmu_build/fmi/fmi2TypesPlatform.h similarity index 100% rename from fmi/fmi2TypesPlatform.h rename to src/mlfmu/fmu_build/fmi/fmi2TypesPlatform.h diff --git a/fmi/fmiFunctions.h b/src/mlfmu/fmu_build/fmi/fmiFunctions.h similarity index 100% rename from fmi/fmiFunctions.h rename to src/mlfmu/fmu_build/fmi/fmiFunctions.h diff --git a/fmi/fmiPlatformTypes.h b/src/mlfmu/fmu_build/fmi/fmiPlatformTypes.h similarity index 100% rename from fmi/fmiPlatformTypes.h rename to src/mlfmu/fmu_build/fmi/fmiPlatformTypes.h diff --git a/templates/fmu/fmu_template.cpp b/src/mlfmu/fmu_build/templates/fmu/fmu_template.cpp similarity index 100% rename from templates/fmu/fmu_template.cpp rename to src/mlfmu/fmu_build/templates/fmu/fmu_template.cpp diff --git a/templates/fmu/model_definitions_template.h b/src/mlfmu/fmu_build/templates/fmu/model_definitions_template.h similarity index 100% rename from templates/fmu/model_definitions_template.h rename to src/mlfmu/fmu_build/templates/fmu/model_definitions_template.h diff --git a/templates/onnx_fmu/onnxFmu.cpp b/src/mlfmu/fmu_build/templates/onnx_fmu/onnxFmu.cpp similarity index 100% rename from templates/onnx_fmu/onnxFmu.cpp rename to src/mlfmu/fmu_build/templates/onnx_fmu/onnxFmu.cpp diff --git a/templates/onnx_fmu/onnxFmu.hpp b/src/mlfmu/fmu_build/templates/onnx_fmu/onnxFmu.hpp similarity index 100% rename from templates/onnx_fmu/onnxFmu.hpp rename to src/mlfmu/fmu_build/templates/onnx_fmu/onnxFmu.hpp diff --git a/src/mlfmu/types/fmu_component.py b/src/mlfmu/types/fmu_component.py index 699c8e5..2dc802c 100644 --- a/src/mlfmu/types/fmu_component.py +++ b/src/mlfmu/types/fmu_component.py @@ -2,10 +2,10 @@ from dataclasses import dataclass from enum import Enum from functools import reduce -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union from uuid import UUID -from pydantic import BaseModel, ConfigDict, StringConstraints, root_validator +from pydantic import BaseModel, ConfigDict, StringConstraints, model_validator from pydantic.fields import Field from typing_extensions import Annotated @@ -101,11 +101,11 @@ class InternalState(BaseModelConfig): examples=["10", "10:20", "30"], ) - @root_validator(allow_reuse=True, skip_on_failure=True) - def check_only_one_initialization(cls, values: Dict[str, Any]): - init_var = "initialization_variable" in values and values["initialization_variable"] is not None - name = "name" in values and values["name"] is not None - start_value = "start_value" in values and values["start_value"] is not None + @model_validator(mode="after") + def check_only_one_initialization(self): + init_var = self.initialization_variable is not None + name = self.name is not None + start_value = self.start_value is not None if init_var and (start_value or name): raise ValueError( @@ -119,7 +119,7 @@ def check_only_one_initialization(cls, values: Dict[str, Any]): raise ValueError( "start_value is set without name being set. Both fields needs to be set for the state initialization to be valid" ) - return values + return self class InputVariable(Variable): @@ -384,7 +384,9 @@ def add_state_initialization_parameters(self, states: List[InternalState]): length=length, is_array=is_array, agent_input_indexes=[], - agent_state_init_indexes=list(range(current_state_index_state, current_state_index_state + length)), + agent_state_init_indexes=[ + list(range(current_state_index_state, current_state_index_state + length)) + ], ) init_parameters.append(init_param) value_reference_start += length diff --git a/src/mlfmu/utils/builder.py b/src/mlfmu/utils/builder.py index 999f387..e345962 100644 --- a/src/mlfmu/utils/builder.py +++ b/src/mlfmu/utils/builder.py @@ -11,16 +11,11 @@ from mlfmu.utils.fmi_builder import generate_model_description from mlfmu.utils.signals import range_list_expanded -# Hard coded values for testing functionality -absolute_path = Path().absolute() -# TODO: I had some problems with this absolute_path.parent.parent, so I changed it to this to make it work. -# These are just temporary hard coded values that should be provided by the user. So it isn't that important. -template_parent_path = absolute_path / "templates" / "fmu" -json_interface = absolute_path / "examples" / "wind_generator" / "config" / "interface.json" -fmu_src_path = absolute_path / "examples" / "wind_generator" -onnx_path = absolute_path / "examples" / "wind_generator" / "config" / "example.onnx" -build_path = absolute_path / "build_fmu" -save_fmu_path = absolute_path / "fmus" +# Paths to files needed for build +path_to_this_file = Path(os.path.abspath(__file__)) +absolute_path = path_to_this_file.parent.parent +fmu_build_folder = absolute_path / "fmu_build" +template_parent_path = fmu_build_folder / "templates" / "fmu" # Replacing all the template strings with their corresponding values and saving to new file @@ -175,8 +170,7 @@ def generate_fmu_files( if error: # Display error and finish workflow - print(error) - return + raise error # Create ONNXModel and FmiModel instances -> load some metadata onnx_model = ONNXModel(onnx_path=onnx_path, time_input=bool(component_model.uses_time)) @@ -223,13 +217,13 @@ def validate_fmu_source_files(fmu_path: os.PathLike[str]): def build_fmu( - fmi_model: FmiModel, fmu_src_path: os.PathLike[str], fmu_build_path: os.PathLike[str], fmu_save_path: os.PathLike[str], ): - validate_fmu_source_files(Path(fmu_src_path) / fmi_model.name) - + fmu_src_path = Path(fmu_src_path) + validate_fmu_source_files(fmu_src_path) + fmu_name = fmu_src_path.stem conan_install_command = [ "conan", "install", @@ -246,27 +240,20 @@ def build_fmu( cmake_set_folders = [ f"-DCMAKE_BINARY_DIR={str(fmu_build_path)}", f"-DFMU_OUTPUT_DIR={str(fmu_save_path)}", - f"-DFMU_NAMES={fmi_model.name}", - f"-DFMU_SOURCE_PATH={str(fmu_src_path)}", + f"-DFMU_NAMES={fmu_name}", + f"-DFMU_SOURCE_PATH={str(fmu_src_path.parent)}", ] cmake_command = ["cmake", *cmake_set_folders, "--preset", "conan-default"] cmake_build_command = ["cmake", "--build", ".", "-j", "14", "--config", "Release"] + cmake_presets_file = Path(fmu_build_folder) / "CMakeUserPresets.json" + cmake_presets_file.unlink(missing_ok=True) + + os.chdir(fmu_build_folder) _ = subprocess.run(conan_install_command) _ = subprocess.run(cmake_command) os.chdir(fmu_build_path) _ = subprocess.run(cmake_build_command) os.chdir(os.getcwd()) - - # TODO: Clean up. - - pass - - -if __name__ == "__main__": - fmi_model = generate_fmu_files(fmu_src_path=fmu_src_path, onnx_path=onnx_path, interface_spec_path=json_interface) - if fmi_model is None: - exit() - build_fmu(fmi_model, fmu_src_path, build_path, save_fmu_path) diff --git a/src/mlfmu/utils/fmi_builder.py b/src/mlfmu/utils/fmi_builder.py index a21617e..428d40a 100644 --- a/src/mlfmu/utils/fmi_builder.py +++ b/src/mlfmu/utils/fmi_builder.py @@ -1,9 +1,8 @@ import datetime +import importlib.metadata as metadata import logging from xml.etree.ElementTree import Element, ElementTree, SubElement, indent -import pkg_resources - from mlfmu.types.fmu_component import ( FmiCausality, FmiModel, @@ -41,7 +40,7 @@ def generate_model_description(fmu_model: FmiModel) -> ElementTree: t = datetime.datetime.now(datetime.timezone.utc) date_str = t.isoformat(timespec="seconds") - TOOL_VERSION = pkg_resources.get_distribution("MLFMU").version + TOOL_VERSION = metadata.version("mlfmu") # Root tag model_description = dict( diff --git a/tests/cli/test_mlfmu_cli.py b/tests/cli/test_mlfmu_cli.py index c137e66..36710e3 100644 --- a/tests/cli/test_mlfmu_cli.py +++ b/tests/cli/test_mlfmu_cli.py @@ -3,11 +3,12 @@ from argparse import ArgumentError from dataclasses import dataclass, field from pathlib import Path -from typing import List, Union +from typing import List, Optional, Union import pytest from pytest import MonkeyPatch +from mlfmu.api import MlFmuCommand from mlfmu.cli import mlfmu from mlfmu.cli.mlfmu import _argparser, main @@ -21,26 +22,24 @@ class CliArgs: verbose: bool = False log: Union[str, None] = None log_level: str = field(default_factory=lambda: "WARNING") - config_file: Union[str, None] = field(default_factory=lambda: "test_config_file") # noqa: N815 - option: bool = False + command: str = "" @pytest.mark.parametrize( "inputs, expected", [ ([], ArgumentError), - (["test_config_file"], CliArgs()), - (["test_config_file", "-q"], CliArgs(quiet=True)), - (["test_config_file", "--quiet"], CliArgs(quiet=True)), - (["test_config_file", "-v"], CliArgs(verbose=True)), - (["test_config_file", "--verbose"], CliArgs(verbose=True)), - (["test_config_file", "-qv"], ArgumentError), - (["test_config_file", "--log", "logFile"], CliArgs(log="logFile")), - (["test_config_file", "--log"], ArgumentError), - (["test_config_file", "--log-level", "INFO"], CliArgs(log_level="INFO")), - (["test_config_file", "--log-level"], ArgumentError), - (["test_config_file", "--option"], CliArgs(option=True)), - (["test_config_file", "-o"], ArgumentError), + (["asd"], ArgumentError), + (["build", "-q"], CliArgs(quiet=True, command="build")), + (["build", "--quiet"], CliArgs(quiet=True, command="build")), + (["build", "-v"], CliArgs(verbose=True, command="build")), + (["build", "--verbose"], CliArgs(verbose=True, command="build")), + (["build", "-qv"], ArgumentError), + (["build", "--log", "logFile"], CliArgs(log="logFile", command="build")), + (["build", "--log"], ArgumentError), + (["build", "--log-level", "INFO"], CliArgs(log_level="INFO", command="build")), + (["build", "--log-level"], ArgumentError), + (["build", "-o"], ArgumentError), ], ) def test_cli( @@ -58,6 +57,8 @@ def test_cli( args_expected: CliArgs = expected args = parser.parse_args() # Assert args + print(args) + print(args_expected) for key in args_expected.__dataclass_fields__: assert args.__getattribute__(key) == args_expected.__getattribute__(key) elif issubclass(expected, Exception): @@ -84,28 +85,28 @@ class ConfigureLoggingArgs: "inputs, expected", [ ([], ArgumentError), - (["test_config_file"], ConfigureLoggingArgs()), - (["test_config_file", "-q"], ConfigureLoggingArgs(log_level_console="ERROR")), + (["build"], ConfigureLoggingArgs()), + (["build", "-q"], ConfigureLoggingArgs(log_level_console="ERROR")), ( - ["test_config_file", "--quiet"], + ["build", "--quiet"], ConfigureLoggingArgs(log_level_console="ERROR"), ), - (["test_config_file", "-v"], ConfigureLoggingArgs(log_level_console="INFO")), + (["build", "-v"], ConfigureLoggingArgs(log_level_console="INFO")), ( - ["test_config_file", "--verbose"], + ["build", "--verbose"], ConfigureLoggingArgs(log_level_console="INFO"), ), - (["test_config_file", "-qv"], ArgumentError), + (["build", "-qv"], ArgumentError), ( - ["test_config_file", "--log", "logFile"], + ["build", "--log", "logFile"], ConfigureLoggingArgs(log_file=Path("logFile")), ), - (["test_config_file", "--log"], ArgumentError), + (["build", "--log"], ArgumentError), ( - ["test_config_file", "--log-level", "INFO"], + ["build", "--log-level", "INFO"], ConfigureLoggingArgs(log_level_file="INFO"), ), - (["test_config_file", "--log-level"], ArgumentError), + (["build", "--log-level"], ArgumentError), ], ) def test_logging_configuration( @@ -129,8 +130,11 @@ def fake_configure_logging( args.log_level_file = log_level_file def fake_run( - config_file: Path, - option: bool, + command: str, + interface_file: Optional[str], + model_file: Optional[str], + fmu_path: Optional[str], + source_folder: Optional[str], ): pass @@ -158,17 +162,18 @@ def fake_run( @dataclass() class ApiArgs: # Values that main() is expected to pass to run() by default when invoking the API - config_file: Path = field(default_factory=lambda: Path("test_config_file")) - option: bool = False + command: Optional[MlFmuCommand] = None + interface_file: Optional[str] = None + model_file: Optional[str] = None + fmu_path: Optional[str] = None + source_folder: Optional[str] = None @pytest.mark.parametrize( "inputs, expected", [ ([], ArgumentError), - (["test_config_file"], ApiArgs()), - (["test_config_file", "--option"], ApiArgs(option=True)), - (["test_config_file", "-o"], ArgumentError), + (["build"], ApiArgs()), ], ) def test_api_invokation( @@ -183,11 +188,17 @@ def test_api_invokation( args: ApiArgs = ApiArgs() def fake_run( - config_file: Path, - option: bool = False, + command: str, + interface_file: Optional[str], + model_file: Optional[str], + fmu_path: Optional[str], + source_folder: Optional[str], ): - args.config_file = config_file - args.option = option + args.command = MlFmuCommand.from_string(command) + args.interface_file = interface_file + args.model_file = model_file + args.fmu_path = fmu_path + args.source_folder = source_folder monkeypatch.setattr(mlfmu, "run", fake_run) # Execute