Source code for esapp.saw.base

import datetime
import locale
import logging
import os
import re
from typing import Union

import pythoncom
import win32com

from ._exceptions import (
    COMError,
    PowerWorldError,
    RPC_S_UNKNOWN_IF,
    RPC_S_CALL_FAILED,
)
from ._helpers import (
    convert_list_to_variant,
    get_temp_filepath,
)
# Set up locale
locale.setlocale(locale.LC_ALL, "")

# noinspection PyPep8Naming
[docs] class SAWBase(object): """Base class for the SimAuto Wrapper, containing core COM functionality.""" SIMAUTO_PROPERTIES = { "CreateIfNotFound": bool, "CurrentDir": str, "UIVisible": bool, } def __init__( self, FileName, early_bind=False, UIVisible=False, CreateIfNotFound: bool = False, UseDefinedNamesInVariables: bool = False, pw_order=False, ) -> None: """Initializes the SimAuto Wrapper (SAW) and establishes a COM connection to PowerWorld Simulator. Parameters ---------- FileName : str Absolute or relative path to the PowerWorld case file (.pwb or .pwx). early_bind : bool, optional If True, uses `gencache` for faster COM calls (requires admin/write access to site-packages). Defaults to False. UIVisible : bool, optional If True, makes the PowerWorld Simulator application window visible. Defaults to False. CreateIfNotFound : bool, optional Sets the SimAuto property to create new objects during `ChangeParameters` calls. Defaults to False. UseDefinedNamesInVariables : bool, optional If True, configures the case to use defined names instead of internal IDs. Defaults to False. pw_order : bool, optional If True, disables automatic sorting of DataFrames to match PowerWorld's internal memory order. Defaults to False. Raises ------ Exception If the SimAuto COM server cannot be initialized (e.g., PowerWorld not installed or license issue). """ self.log = logging.getLogger(self.__class__.__name__) locale_db = locale.localeconv() self.decimal_delimiter = locale_db["decimal_point"] pythoncom.CoInitialize() try: if early_bind: try: self._pwcom = win32com.client.gencache.EnsureDispatch("pwrworld.SimulatorAuto") except AttributeError: # pragma: no cover self._pwcom = win32com.client.dynamic.Dispatch("pwrworld.SimulatorAuto") else: self._pwcom = win32com.client.dynamic.Dispatch("pwrworld.SimulatorAuto") except Exception as e: m = ( "Unable to launch SimAuto. Please confirm that your PowerWorld license includes " "the SimAuto add-on, and that SimAuto has been successfully installed." ) self.log.exception(m) raise e self.pwb_file_path = None self.set_simauto_property("CreateIfNotFound", CreateIfNotFound) self.set_simauto_property("UIVisible", UIVisible) self.pw_order = pw_order # Initialize temporary file for UI updates self.empty_aux = get_temp_filepath(".axd") with open(self.empty_aux, "w") as f: pass self.OpenCase(FileName=FileName) version_string, self.build_date = self.get_version_and_builddate() self.version = int(re.search(r"\d+", version_string)[0]) if UseDefinedNamesInVariables: self.exec_aux( 'CaseInfo_Options_Value (Option,Value)\n{"UseDefinedNamesInVariables" "YES"}' ) self._object_fields = {}
[docs] def exit(self): """Closes the PowerWorld case, deletes temporary files, and releases the COM object. This method should be called when the SimAuto session is no longer needed to ensure proper cleanup and resource release. """ if os.path.exists(self.empty_aux): os.unlink(self.empty_aux) self.CloseCase() del self._pwcom self._pwcom = None pythoncom.CoUninitialize() return None
[docs] def set_simauto_property(self, property_name: str, property_value: Union[str, bool]): """Sets a property on the underlying SimAuto COM object. This method provides a controlled way to set various SimAuto properties, including validation of property names and value types. Parameters ---------- property_name : str The name of the property to set (e.g., 'UIVisible', 'CurrentDir', 'CreateIfNotFound'). property_value : Union[str, bool] The value to assign to the property. The type must match the expected type for the specific property. Raises ------ ValueError If the `property_name` is unsupported, the `property_value` has an incorrect type, or if `CurrentDir` is set to an invalid path. AttributeError If the property does not exist on the current SimAuto version (e.g., `UIVisible` on older versions of Simulator). """ if property_name not in self.SIMAUTO_PROPERTIES: raise ValueError( f"The given property_name, {property_name}, is not currently supported. " f"Valid properties are: {list(self.SIMAUTO_PROPERTIES.keys())}" ) if not isinstance(property_value, self.SIMAUTO_PROPERTIES[property_name]): m = ( f"The given property_value, {property_value}, is invalid. " f"It must be of type {self.SIMAUTO_PROPERTIES[property_name]}." ) raise ValueError(m) if property_name == "CurrentDir" and not os.path.isdir(property_value): raise ValueError(f"The given path for CurrentDir, {property_value}, is not a valid path!") try: self._set_simauto_property(property_name=property_name, property_value=property_value) except AttributeError as e: if property_name == "UIVisible": self.log.warning( "UIVisible attribute could not be set. Note this SimAuto property was not introduced " "until Simulator version 20. Check your version with the get_simulator_version method." ) else: raise e from None
def _set_simauto_property(self, property_name, property_value): """Internal helper to directly set a SimAuto COM property.""" setattr(self._pwcom, property_name, property_value)
[docs] def ProcessAuxFile(self, FileName): """Executes a PowerWorld auxiliary (.aux) file. Auxiliary files contain script commands or data definitions that PowerWorld can process to modify the case or perform actions. Parameters ---------- FileName : str Path to the auxiliary (.aux) file. Returns ------- None Raises ------ PowerWorldError If the SimAuto call fails (e.g., file not found, syntax error in aux file). """ return self._com_call("ProcessAuxFile", FileName)
def _run_script(self, command: str, *args) -> None: """Execute a PowerWorld script command with optional arguments. This is the standard way for mixin methods to invoke PowerWorld script commands. It builds the command string and routes it through ``RunScriptCommand``. Parameters ---------- command : str The script command name (e.g., ``"SolvePowerFlow"``, ``"GICCalculate"``). *args Arguments to pass to the command. ``None`` values are converted to empty strings. Trailing ``None`` values are stripped. Returns ------- None Raises ------ PowerWorldError If the script command fails. """ # Strip trailing Nones arg_list = list(args) while arg_list and arg_list[-1] is None: arg_list.pop() if arg_list: arg_str = ", ".join("" if a is None else str(a) for a in arg_list) stmt = f"{command}({arg_str});" else: stmt = f"{command};" self.log.debug("RunScript: %s", stmt) return self.RunScriptCommand(stmt)
[docs] def RunScriptCommand(self, Statements): """Executes one or more PowerWorld script statements. Parameters ---------- Statements : str A string containing one or more PowerWorld script commands, separated by semicolons. See the "SCRIPT Section" in the Auxiliary File Format PDF for command syntax. Returns ------- None Raises ------ PowerWorldError If any of the script commands fail. """ return self._com_call("RunScriptCommand", Statements)
[docs] def RunScriptCommand2(self, Statements: str, StatusMessage: str): """Executes script statements and provides a status message for the PowerWorld UI. This method is similar to `RunScriptCommand` but also allows displaying a custom message in the PowerWorld Simulator status bar. Parameters ---------- Statements : str A string containing one or more PowerWorld script commands. See the "SCRIPT Section" in the Auxiliary File Format PDF for command syntax. StatusMessage : str A message to display in the PowerWorld Simulator status bar while the commands are being executed. Returns ------- None Raises ------ PowerWorldError If any of the script commands fail. """ return self._com_call("RunScriptCommand2", Statements, StatusMessage)
@property def CreateIfNotFound(self): return self._pwcom.CreateIfNotFound @property def CurrentDir(self) -> str: return self._pwcom.CurrentDir @property def ProcessID(self) -> int: return self._pwcom.ProcessID @property def RequestBuildDate(self) -> int: return self._pwcom.RequestBuildDate @property def UIVisible(self) -> bool: try: return self._pwcom.UIVisible except AttributeError: self.log.warning( "UIVisible attribute could not be accessed. Note this SimAuto property was not introduced " "until Simulator version 20. Check your version with the get_simulator_version method." ) return False @property def ProgramInformation(self) -> Union[tuple, bool]: """Tuple property: Detailed information about the Simulator version and license.""" try: result = self._pwcom.ProgramInformation result = [list(x) for x in result] result[0][2] = datetime.datetime.fromtimestamp(result[0][2].timestamp(), tz=result[0][2].tzinfo) result = tuple(tuple(x) for x in result) return result except AttributeError: # pragma: no cover self.log.warning( "ProgramInformation attribute could not be accessed. Note this SimAuto property was not " "introduced until Simulator version 21. Check your version with the get_simulator_version method." ) return False def _com_call(self, func: str, *args): """Internal helper to execute SimAuto COM methods and handle error codes. This method wraps all direct COM calls to PowerWorld Simulator, providing consistent error handling and unwrapping of results. Parameters ---------- func : str The name of the SimAuto method to call (e.g., "OpenCase", "GetParametersMultipleElement"). *args : Any Variable arguments to pass to the SimAuto method. These are typically converted to COM-compatible types (e.g., variants) before the call. Returns ------- Any The data returned by SimAuto, unwrapped from the (Error, Result) tuple. Returns None if SimAuto indicates no data or an empty result. Raises ------ AttributeError If `func` is not a valid SimAuto function. COMError If a COM-specific error occurs during the call. PowerWorldError If SimAuto returns an error message (e.g., invalid parameters, operation failed). """ self.log.debug("COM call: %s(%s)", func, ", ".join(repr(a) for a in args)) try: f = getattr(self._pwcom, func) except AttributeError: raise AttributeError(f"The given function, {func}, is not a valid SimAuto function.") from None try: output = f(*args) except Exception as e: # Handle specific RPC server unavailable/unknown interface errors msg = str(e) if hex(RPC_S_UNKNOWN_IF) in msg or hex(RPC_S_CALL_FAILED) in msg: m = f"SimAuto server crashed or is unresponsive during call to {func} with {args}. (RPC Error)" self.log.critical(m) m = f"An error occurred when trying to call {func} with {args}" self.log.exception(m) raise COMError(m) from e if output == ("",): return None try: if output is None or output[0] == "": pass elif not isinstance(output[0], str): pass elif "No data" not in output[0]: raise PowerWorldError.from_message(output[0]) except TypeError as e: if "is not subscriptable" in e.args[0]: if output == -1: m = ( f"PowerWorld simply returned -1 after calling '{func}' with '{args}'. " "Unfortunately, that's all we can help you with. Perhaps the arguments are " "invalid or in the wrong order - double-check the documentation." ) raise PowerWorldError(m) from e elif isinstance(output, int): return output raise e return output[1] if len(output) == 2 else output[1:] def _replace_decimal_delimiter(self, data): """Internal helper to replace locale-specific decimal delimiters with '.' in a Series. Parameters ---------- data : pandas.Series The Series whose string elements might contain locale-specific decimal delimiters. Returns ------- pandas.Series A new Series with decimal delimiters replaced, or the original Series if it does not contain string data. """ try: return data.str.replace(self.decimal_delimiter, ".") except AttributeError: return data
[docs] def exec_aux(self, aux: str, use_double_quotes: bool = False): """Executes an auxiliary command string directly. This method writes the provided `aux` string to a temporary .aux file and then processes it using `ProcessAuxFile`. Parameters ---------- aux : str The auxiliary command string to execute. use_double_quotes : bool, optional If True, single quotes in `aux` will be replaced with double quotes. Defaults to False. """ if use_double_quotes: aux = aux.replace("'", '"') fpath = get_temp_filepath(".aux") try: with open(fpath, "w") as f: f.write(aux) self.ProcessAuxFile(fpath) finally: os.unlink(fpath)
[docs] def update_ui(self) -> None: """Triggers a refresh of the PowerWorld Simulator user interface. This can be useful after making programmatic changes that might not immediately reflect in the GUI. """ return self.ProcessAuxFile(self.empty_aux)
[docs] def set_logging_level(self, level: Union[int, str]) -> None: """Sets the logging level for the SAW instance logger. Parameters ---------- level : int or str The logging level (e.g., logging.DEBUG, "DEBUG"). """ self.log.setLevel(level)