From a9836c691e7c1cb599ba5a7c50120fcb6fd1e0c4 Mon Sep 17 00:00:00 2001 From: "joaquin.f.fernandez" Date: Fri, 8 Nov 2024 14:23:05 -0300 Subject: [PATCH] [iss-268] Squashed commit of the following: commit e4c8f5ca6ff923f37a80a72dc3c9e3b6eabfc9fb Author: joaquin.f.fernandez Date: Fri Nov 8 14:22:00 2024 -0300 Remove init config file. commit 51269a50b60b6a156110b7c277a86d9dee66c5e8 Author: joaquin.f.fernandez Date: Fri Nov 8 14:08:31 2024 -0300 Add initialization script. commit 8071e77e6b283f6ee7ae4241d435c801be2237bb Author: joaquin.f.fernandez Date: Fri Nov 8 14:07:48 2024 -0300 Add simple Python module for qss_solver. --- src/python/qss_solver/__init__ .py | 3 + src/python/qss_solver/file_handlers.py | 156 ++++++++++++++++ src/python/qss_solver/model.py | 244 +++++++++++++++++++++++++ src/python/qss_solver/results.py | 62 +++++++ src/python/qss_solver/simulate.py | 62 +++++++ 5 files changed, 527 insertions(+) create mode 100644 src/python/qss_solver/__init__ .py create mode 100644 src/python/qss_solver/file_handlers.py create mode 100644 src/python/qss_solver/model.py create mode 100644 src/python/qss_solver/results.py create mode 100644 src/python/qss_solver/simulate.py diff --git a/src/python/qss_solver/__init__ .py b/src/python/qss_solver/__init__ .py new file mode 100644 index 00000000..0e3acc3a --- /dev/null +++ b/src/python/qss_solver/__init__ .py @@ -0,0 +1,3 @@ +from .results import * +from .model import * +from .simulate import * diff --git a/src/python/qss_solver/file_handlers.py b/src/python/qss_solver/file_handlers.py new file mode 100644 index 00000000..b3a3c33d --- /dev/null +++ b/src/python/qss_solver/file_handlers.py @@ -0,0 +1,156 @@ +import logging +import os +from pathlib import Path + +# Configure logging +logging.basicConfig(level=logging.DEBUG, format='%(levelname)s - %(message)s') + +CONFIG = { + 'MMOC_MODELS': { + 'path': os.environ['MMOC_MODELS'], + 'ext': ".mo" + }, + 'MMOC_LOG': { + 'path': os.environ['MMOC_OUTPUT'], + 'ext': ".log" + }, + 'MMOC_BUILD': { + 'path': os.environ['MMOC_BUILD'], + 'ext': ".ini" + } +} + +def has_extension(file_name): + """ + Check if the given file name has an extension. + + Parameters: + - file_name: The file name to check. + + Returns: + - True if the file name has an extension, False otherwise. + """ + # Check if the file name has an extension + return os.path.splitext(file_name)[1] != '' + +def get_base_name(file_path): + """ + Get the base path of the given file. + + Parameters: + - file_path: Path to the file. + + Returns: + - The base path (directory) of the file. + """ + # Use os.path.splitext to split the file name and extension + file_name, _ = os.path.splitext(os.path.basename(file_path)) + return file_name + + +def find_model_file(rel_path, config_entry): + """Looks for a .mo file relative to MMOC_MODELS directory first. + + Args: + rel_path (str): The relative path to the model file. + + Returns: + Path | None: The path to the model file if found, otherwise None. + """ + logging.debug(f"Searching for model file with relative path: {rel_path}") + + # Create a Path object from the relative path + path = Path(rel_path) + + # Get the base file name without extension + base_file_name = path.stem + logging.debug(f"Base file name without extension: {path.parent}") + + if path.parent == Path('.'): + model_path = base_file_name + else: + model_path = (path.parent).relative_to('') + + # Construct the model directory path + model_directory = Path(CONFIG[config_entry]['path']) / model_path / f"{base_file_name}{CONFIG[config_entry]['ext']}" + logging.debug(f"Constructed model directory path: {model_directory}") + + # Check if the file exists in the models directory + if model_directory.is_file(): + logging.debug(f"Model file found: {model_directory}") + return model_directory + + # Construct the model directory path + model_directory = Path(CONFIG[config_entry]['path']) / f"{base_file_name}{CONFIG[config_entry]['ext']}" + logging.debug(f"Constructed model directory path: {model_directory}") + + # Check if the file exists in the models directory + if model_directory.is_file(): + logging.debug(f"Model file found: {model_directory}") + return model_directory + + # If the file is not found, return None + return None + +def is_relative_path(file_name): + """ + Check if the given file name has a relative path. + + Parameters: + - file_name: The file name to check. + + Returns: + - True if the file name is a relative path, False otherwise. + """ + # Check if the file name is relative + return not os.path.isabs(file_name) + +import os + +def get_full_path(file_path, config_entry='MMOC_MODELS'): + """ + Get the full path of the given file. + + Parameters: + - file_path: Path to the file. + + Returns: + - The full path of the file. + """ + + if is_relative_path(file_path): + file_path = find_model_file(file_path, config_entry) + + if file_path == None: + return "" + + return os.path.abspath(file_path) + +def get_base_path(file_path, config_entry='MMOC_MODELS'): + """ + Get the base path of the given file. + + Parameters: + - file_path: Path to the file. + + Returns: + - The base path (directory) of the file. + """ + return os.path.dirname(get_full_path(file_path, config_entry)) + +def get_file_name(file_path, config_entry='MMOC_MODELS'): + """ + Get the file name without the extension. + + Parameters: + - file_path: Path to the file. + + Returns: + - The file name without the extension. + """ + if is_relative_path(file_path): + file_path = find_model_file(file_path, config_entry) + + if file_path == None: + return "" + return get_base_name(file_path) \ No newline at end of file diff --git a/src/python/qss_solver/model.py b/src/python/qss_solver/model.py new file mode 100644 index 00000000..d220f8cc --- /dev/null +++ b/src/python/qss_solver/model.py @@ -0,0 +1,244 @@ +import configparser +import json +import re + +import file_handlers as fh + +def annotations(model): + """ + Extract annotations from a Modelica model file. + + :param model: The name or path of the Modelica file to read. + :return: A dictionary containing the key-value pairs of the experiment annotations, or None if no annotations are found. + """ + model_path = fh.get_full_path(model) + + # Read the Modelica code from the specified file + with open(model_path, 'r') as file: + modelica_model = file.read() + + # Regular expression to find the annotation section + pattern = r'annotation\s*\(\s*experiment\s*\(\s*(.*?)\s*\)\s*\);' + + # Search for the pattern in the provided Modelica code + match = re.search(pattern, modelica_model, re.DOTALL) + + if not match: + return None + + # Extract the content inside the experiment(...) + experiment_content = match.group(1) + + # Split the content into key-value pairs + parameters = re.findall(r'(\w+)\s*=\s*([^,]+)', experiment_content) + + # Create a dictionary from the key-value pairs + result = {key.strip(): value.strip() for key, value in parameters} + + return result + +def set_annotations(model, annotations): + """ + Replace Modelica annotations in a file with the provided annotations dictionary. + + :param model: Path to the Modelica file. + :param annotations: Dictionary containing the Modelica annotations. + """ + model_path = fh.get_full_path(model) + + # Read the existing content of the Modelica file + with open(model_path, 'r') as file: + modelica_code = file.read() + + # Build the new annotation code + new_annotation_code = "annotation(\n experiment(\n" + for key, value in annotations.items(): + new_annotation_code += f" {key} = {value},\n" + new_annotation_code = new_annotation_code.rstrip(",\n") + "\n )\n);\n" + + # Regular expression to find the existing annotation section + pattern = r'annotation\s*\(\s*experiment\s*\(\s*(.*?)\s*\)\s*\);' + + # Replace the existing annotation with the new one + modified_code = re.sub(pattern, new_annotation_code, modelica_code, flags=re.DOTALL) + + # Write the modified content back to the file + with open(model_path, 'w') as file: + file.write(modified_code) + +def set_constant(model, variable_name, new_value): + """ + Change the value of a constant Integer variable in a Modelica model. + + :param model: Path to the Modelica file to modify. + :param variable_name: The name of the constant Integer variable to change. + :param new_value: The new value to set for the constant Integer variable. + """ + + model_path = fh.get_full_path(model) + + # Read the existing content of the Modelica file + with open(model_path, 'r') as file: + modelica_code = file.read() + + # Regular expression to find the constant Integer variable + pattern = rf'constant\s+Integer\s+{variable_name}\s*=\s*\d+;' + + # Create the new declaration with the new value + new_declaration = f'constant Integer {variable_name} = {new_value};' + + # Replace the old declaration with the new one + modified_code = re.sub(pattern, new_declaration, modelica_code) + + # Write the modified content back to the file + with open(model_path, 'w') as file: + file.write(modified_code) + +def set_constants(model, constants): + """ + Change the value of a constant Integer variable from the given dictionary. + + :param model: Path to the Modelica file to modify. + :param constants: Dictionary containing the Modelica constants. + """ + for key, value in constants.items(): + set_constant(model, key, value) + + +def constants(model): + """ + Read all constant Integer variables from a Modelica model. + + :param model: Path to the Modelica file to read. + :return: A dictionary with variable names as keys and their values as integers. + """ + model_path = fh.get_full_path(model) + + # Initialize an empty dictionary to store the constant Integer variables + constant_integers = {} + + # Read the existing content of the Modelica file + with open(model_path, 'r') as file: + modelica_code = file.read() + + # Regular expression to find constant Integer declarations + pattern = r'constant\s+Integer\s+(\w+)\s*=\s*(\d+);' + + # Find all matches in the Modelica code + matches = re.findall(pattern, modelica_code) + + # Populate the dictionary with variable names and their integer values + for variable_name, value in matches: + constant_integers[variable_name] = int(value) + + return constant_integers + +def parameters(model): + """ + Read a JSON file containing a list of parameter records with values that are either + a double or a list of doubles. + + :param model: Path to the JSON file to read. + :return: A list of dictionaries with the parsed data. + :raises ValueError: If any value is not a double or a list of doubles. + """ + + model_path = fh.get_full_path(model) + + # Initialize an empty list to store the records + records = [] + + # Read the JSON file + with open(model_path, 'r') as file: + data = json.load(file) + + # Validate and parse the records + for record in data: + parsed_record = {} + for key, value in record.items(): + if isinstance(value, (float, int)): # Check if value is a double (float or int) + parsed_record[key] = float(value) + elif isinstance(value, list): # Check if value is a list + if all(isinstance(item, (float, int)) for item in value): # Ensure all items are doubles + parsed_record[key] = [float(item) for item in value] + else: + raise ValueError(f"Invalid value in list for key '{key}': {value}") + else: + raise ValueError(f"Invalid value for key '{key}': {value}") + + records.append(parsed_record) + + return records + +def set_parameters(model, parameters): + """ + Generate a JSON parameters file with values that are either a double or a list of doubles. + + :param model: Path to the JSON file to write. + :param parameters: A list of dictionaries containing the records to write. + :raises ValueError: If any value in the records is not a double or a list of doubles. + """ + + for record in parameters: + for key, value in record.items(): + if not (isinstance(value, (float, int)) or + (isinstance(value, list) and all(isinstance(item, (float, int)) for item in value))): + raise ValueError(f"Invalid value for key '{key}': {value}") + + model_path = fh.get_full_path(model) + + # Write the records to the JSON file + with open(model_path, 'w') as file: + json.dump(parameters, file, indent=4) # Use indent for pretty printing + + +def config(model): + """ + Reads the model configuration from a given INI file and returns its contents as a dictionary. + + :param model: The name of the INI file to read. + :return: A dictionary containing the configuration key-value pairs. + """ + config = configparser.ConfigParser() + + config_path = fh.get_full_path(model, 'MMOC_BUILD') + + # Read the INI file + with open(config_path, 'r') as file: + first_line = file.readline().strip() + # Check if the first line is empty or does not start with a section header + if not first_line.startswith('['): + # If no section header, prepend a default section header + file.seek(0) # Reset file pointer to the beginning + content = '[QSS_SOLVER]\n' + file.read() # Add the section header + with open(config_path, 'w') as write_file: + write_file.write(content) # Write back the modified content + + # Read the INI file + config.read(config_path) + + # Convert the ConfigParser object to a dictionary + ini_dict = {key: value for key, value in config['QSS_SOLVER'].items()} + + # Remove trailing semicolons from the values + ini_dict = {key: value.rstrip(';') for key, value in ini_dict.items()} + + return ini_dict + +def set_config(model, data_dict): + """ + Writes a dictionary with ini file info to an INI file. + + :param model: The name or path of the INI file to write. + :param data_dict: A dictionary containing the configuration data to write. + """ + config = configparser.ConfigParser() + + # Add a section named 'QSS_SOLVER' and populate it with the dictionary items + config['QSS_SOLVER'] = {key: f"{value};" for key, value in data_dict.items()} + + config_path = fh.get_full_path(model, 'MMOC_BUILD') + + # Write the configuration to the specified file + with open(config_path, 'w') as configfile: + config.write(configfile) diff --git a/src/python/qss_solver/results.py b/src/python/qss_solver/results.py new file mode 100644 index 00000000..0f314409 --- /dev/null +++ b/src/python/qss_solver/results.py @@ -0,0 +1,62 @@ +import os + +import file_handlers as fh + +def simulation_log(model): + """ + Reads the simulation log for a given model and returns a dictionary + of key-value pairs extracted from the log file. + + Parameters: + model: The name of the model for which to read the log. + + Returns: + dict: A dictionary containing the log data, excluding the 'Simulation output' key. + """ + # Initialize an empty dictionary to store the results + results = {} + + model_path = fh.get_full_path(model, 'MMOC_LOG') + + # Read the log data from the file + with open(model_path, 'r') as file: + log = file.read() + + # Split the log into lines + lines = log.strip().split('\n') + + # Iterate through each line + for line in lines: + # Split the line at the colon + if ':' in line: + key, value = line.split(':', 1) + # Strip whitespace, remove 'ms', and convert to float + cleaned_value = value.strip().replace(' ms', '') + # Store the value in the dictionary if the key is not 'Simulation output' + if key.strip() != 'Simulation output': + results[key.strip()] = float(cleaned_value) + + return results + +def output_files(model): + """ + Returns a list of all '.dat' files in the specified output folder + for a given model. + + Parameters: + model: The name of the model for which to list the output files. + + Returns: + list: A list of absolute paths to all '.dat' files in the model's output directory. + """ + output_path = os.path.join(os.environ['MMOC_OUTPUT'], model) + + dat_files = [] + + # Iterate through the files in the given folder + for file_name in os.listdir(output_path): + # Check if the file ends with '.dat' + if file_name.endswith('.dat'): + dat_files.append(os.path.abspath(os.path.join(output_path, file_name))) + + return dat_files \ No newline at end of file diff --git a/src/python/qss_solver/simulate.py b/src/python/qss_solver/simulate.py new file mode 100644 index 00000000..638fd87c --- /dev/null +++ b/src/python/qss_solver/simulate.py @@ -0,0 +1,62 @@ +import logging +import os +import subprocess + +import file_handlers as fh + +def compile_model(model_file, flags=''): + """ + Compile the specified Modelica model. + + Parameters: + - model_file: Path to the model file. + - flags: Additional flags for compilation. + """ + compile_cmd = os.path.join(os.environ['MMOC_BIN'], 'compile.sh') + + model_name = fh.get_file_name(model_file) + model_path = fh.get_base_path(model_file) + + logging.info(f'Compiling model: {model_name}') + try: + subprocess.check_call([compile_cmd, model_name, model_path, flags]) + logging.info('Compilation done') + except subprocess.CalledProcessError as e: + logging.error(f"Compilation failed for {model_name}: {e}") + return False + return True + +def execute_model(model_file): + """ + Run the executable model given. + + Parameters: + - model_file: Path to the model file. + """ + + model_name = fh.get_file_name(model_file) + simulation_cmd = os.path.join(os.environ['MMOC_BIN'], 'simulate.sh') + + logging.info(f'Running executable model: {model_name}') + + try: + subprocess.check_call([simulation_cmd, model_name, 'false' , 'false']) + logging.info('Simulation done') + except subprocess.CalledProcessError as e: + logging.error(f"Simulation failed for {model_name}: {e}") + return False + + return True + +def run(model_file, flags=''): + """ + Compile and run the executable model. + + Parameters: + - model_file: Path to the model file. + - flags: Additional compilation flags. + """ + # Compile the model + if compile_model(model_file, flags): + # Execute the model if compilation was successful + execute_model(model_file)