From 9c8fb8db807c45cba5323e6bba4237fe6b0dad10 Mon Sep 17 00:00:00 2001 From: Wyatt Lowery Date: Tue, 6 Jan 2026 01:17:39 -0600 Subject: [PATCH 1/2] Add v24 Cases, new func, and test --- esa/saw.py | 162 +++++++++++++----- temp_test.py | 18 ++ tests/cases/ieee_14/IEEE 14 bus.pwd | 4 +- .../ieee_14/IEEE 14 bus_pws_version_24.PWB | 3 + tests/cases/tx2000/tx2000.pwd | 4 +- .../tx2000/tx2000_base_pws_version_24.PWB | 3 + tests/cases/wscc_9/WSCC 9 bus.pwd | 4 +- .../wscc_9/WSCC 9 bus_pws_version_24.PWB | 3 + tests/test_saw.py | 63 ++++--- 9 files changed, 190 insertions(+), 74 deletions(-) create mode 100644 temp_test.py create mode 100644 tests/cases/ieee_14/IEEE 14 bus_pws_version_24.PWB create mode 100644 tests/cases/tx2000/tx2000_base_pws_version_24.PWB create mode 100644 tests/cases/wscc_9/WSCC 9 bus_pws_version_24.PWB diff --git a/esa/saw.py b/esa/saw.py index c8ba6370..f7708daa 100644 --- a/esa/saw.py +++ b/esa/saw.py @@ -779,49 +779,84 @@ def get_shunt_admittance(self): df.fillna(0, inplace=True) return (df['BusSSMW'].to_numpy() + 1j * df['BusSS'].to_numpy()) / base - def get_jacobian(self, full=False): - """Helper function to get the Jacobian matrix, by default return a - scipy sparse matrix in the csr format - :param full: Convert the csr_matrix to the numpy array (full matrix). + def get_gmatrix(self, full: bool = False) -> Union[np.ndarray, csr_matrix]: + """Helper function to get the GIC conductance matrix (G). + + By default, this function returns a scipy sparse matrix in csr format. + + :param full: If True, convert the csr_matrix to a dense numpy array. + :return: The GIC conductance matrix as a sparse or dense matrix. """ - jacfile = tempfile.NamedTemporaryFile(mode='w', suffix='.m', - delete=False) - jacfile_path = Path(jacfile.name).as_posix() - jacfile.close() - jidfile = tempfile.NamedTemporaryFile(mode='w', delete=False) - jidfile_path = Path(jidfile.name).as_posix() - jidfile.close() - cmd = f'SaveJacobian("{jacfile_path}","{jidfile_path}",M,R);' - self.RunScriptCommand(cmd) - with open(jacfile_path, 'r') as f: - mat_str = f.read() - os.unlink(jacfile.name) - os.unlink(jidfile.name) - mat_str = re.sub(r'\s', '', mat_str) - lines = re.split(';', mat_str) - ie = r'[0-9]+' - fe = r'-*[0-9]+\.[0-9]+' - dr = re.compile(r'(?:Jac)=(?:sparse\()({ie})'.format(ie=ie)) - exp = re.compile( - r'(?:Jac\()({ie}),({ie})(?:\)=)({fe})'.format( - ie=ie, fe=fe)) - row = [] - col = [] - data = [] - # Get the dimension from the first line in lines - dim = dr.match(lines[0])[1] - n = int(dim) - for line in lines[1:]: - match = exp.match(line) - if match is None: - continue - idx1, idx2, real = match.groups() - row.append(int(idx1)) - col.append(int(idx2)) - data.append(float(real)) - sparse_matrix = csr_matrix( - (data, (np.asarray(row) - 1, np.asarray(col) - 1)), shape=(n, n)) - return sparse_matrix.toarray() if full else sparse_matrix + # Create temporary files for PowerWorld to write to + g_matrix_file = tempfile.NamedTemporaryFile(mode="w", suffix=".m", delete=False) + id_file = tempfile.NamedTemporaryFile(mode="w", delete=False) + + try: + g_matrix_path = Path(g_matrix_file.name).as_posix() + id_file_path = Path(id_file.name).as_posix() + g_matrix_file.close() + id_file.close() + + # Tell PowerWorld to write the G matrix to the files + cmd = f'GICSaveGMatrix("{g_matrix_path}","{id_file_path}");' + + # NOTE: PowerWorld seems to require the command to be called twice + # to ensure all data is written to the file. + self.RunScriptCommand(cmd) + self.RunScriptCommand(cmd) + + # Read the matrix data from the temporary file + with open(g_matrix_path, "r") as f: + mat_str = f.read() + + # Parse the matrix string + sparse_matrix = self._parse_real_matrix(mat_str, 'GMatrix') + + return sparse_matrix.toarray() if full else sparse_matrix + + finally: + # Ensure temporary files are deleted. + os.unlink(g_matrix_file.name) + os.unlink(id_file.name) + + def get_jacobian(self, full: bool = False) -> Union[np.ndarray, csr_matrix]: + """Helper function to get the Jacobian matrix. + + By default, this function returns a scipy sparse matrix in csr format. + + Note: The power flow must be solved before calling this function, otherwise + an error will be raised by PowerWorld. + + :param full: If True, convert the csr_matrix to a dense numpy array. + :return: The Jacobian matrix as a sparse or dense matrix. + """ + # Create temporary files for PowerWorld to write to + jac_file = tempfile.NamedTemporaryFile(mode="w", suffix=".m", delete=False) + id_file = tempfile.NamedTemporaryFile(mode="w", delete=False) + + try: + jac_file_path = Path(jac_file.name).as_posix() + id_file_path = Path(id_file.name).as_posix() + jac_file.close() + id_file.close() + + # Tell PowerWorld to write the Jacobian matrix to the files. + cmd = f'SaveJacobian("{jac_file_path}","{id_file_path}",M,R);' + self.RunScriptCommand(cmd) + + # Read the matrix data from the temporary file. + with open(jac_file_path, "r") as f: + mat_str = f.read() + + # Parse the matrix string into a sparse matrix. + sparse_matrix = self._parse_real_matrix(mat_str, "Jac") + + return sparse_matrix.toarray() if full else sparse_matrix + + finally: + # Ensure temporary files are deleted. + os.unlink(jac_file.name) + os.unlink(id_file.name) def to_graph(self, node: str = 'bus', geographic: bool = False, directed: bool = False, node_attr=None, edge_attr=None) \ @@ -3010,7 +3045,7 @@ def CloseOneline(self, OnelineName: str = "") -> None: :returns: None """ - script = f'CloseOneline({OnelineName})' + script = f'CloseOneline("{OnelineName}")' return self.RunScriptCommand(script) #################################################################### @@ -3335,6 +3370,47 @@ def _replace_decimal_delimiter(self, data: pd.Series): except AttributeError: return data + def _parse_real_matrix(self, mat_str, matrix_name="Jac"): + """Helper to parse a real-valued sparse matrix from '.m' PowerWorld output. + + :param mat_str: String representation of a real-valued sparse + matrix, as returned by PowerWorld. + :param matrix_name: Name of the matrix to be parsed. + :returns: CSR sparse matrix. + """ + # Regex for File Format + mat_str = re.sub(r"\s", "", mat_str) + lines = re.split(";", mat_str) + ie = r"[0-9]+" + fe = r"-*[0-9]+\.[0-9]+" + + # Regex for Data Extraction + dr_raw = r"(?:{matrix_name})=(?:sparse\()({ie})".format(ie=ie, matrix_name=matrix_name) + exp_raw = r"(?:{matrix_name}\()({ie}),({ie})(?:\)=)({fe})".format(ie=ie, fe=fe, matrix_name=matrix_name) + dr = re.compile(dr_raw) + exp = re.compile(exp_raw) + + + # Get the dimension from the first line in lines + dim = dr.match(lines[0])[1] + n = int(dim) + + # Empty Lists + row, col, data = [], [], [] + + for line in lines[1:]: + match = exp.match(line) + if match is None: + continue + idx1, idx2, real = match.groups() + row.append(int(idx1)) + col.append(int(idx2)) + data.append(float(real)) + + # Return CSR Sparse Matrix + return csr_matrix( + (data, (np.asarray(row) - 1, np.asarray(col) - 1)), shape=(n, n) + ) def df_to_aux(fp, df, object_name: str): """ Convert a dataframe to PW aux/axd data section. diff --git a/temp_test.py b/temp_test.py new file mode 100644 index 00000000..79ca98ca --- /dev/null +++ b/temp_test.py @@ -0,0 +1,18 @@ +CASE_PATH = r"C:\Users\wyatt\OneDrive - Texas A&M University\Research\Cases\Hawaii\Hawaii40_20231026.pwb" + +from esa import SAW + + +saw = SAW(FileName=CASE_PATH) + +bus_data = saw.get_power_flow_results('bus') + +G = saw.get_gmatrix() + +saw.SolvePowerFlow() + +# NOTE Jacobean returns error until after power flow is solved +J = saw.get_jacobian() + +print("Jacobian Matrix:", J.shape, J.nnz) +print("G Matrix:", G.shape, G.nnz) \ No newline at end of file diff --git a/tests/cases/ieee_14/IEEE 14 bus.pwd b/tests/cases/ieee_14/IEEE 14 bus.pwd index 37178027..4b52400d 100644 --- a/tests/cases/ieee_14/IEEE 14 bus.pwd +++ b/tests/cases/ieee_14/IEEE 14 bus.pwd @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c0ee81c7e9070799ec1d40e981aadfe9e948f201d7e08e72c88ba6e1ae6bca8 -size 20544 +oid sha256:ae28f12410fc86b1ba73e3722b17b2da08459f3f1f270f29d0432a847417187b +size 22778 diff --git a/tests/cases/ieee_14/IEEE 14 bus_pws_version_24.PWB b/tests/cases/ieee_14/IEEE 14 bus_pws_version_24.PWB new file mode 100644 index 00000000..facb5442 --- /dev/null +++ b/tests/cases/ieee_14/IEEE 14 bus_pws_version_24.PWB @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de77fcb80910208e89df9966d94f387d8d5206d66fe3e2ea0d79360388150629 +size 38227 diff --git a/tests/cases/tx2000/tx2000.pwd b/tests/cases/tx2000/tx2000.pwd index a53673bf..29e665cb 100644 --- a/tests/cases/tx2000/tx2000.pwd +++ b/tests/cases/tx2000/tx2000.pwd @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d6198a9d4cb5b26ed1be4980460e64efc50b408f9062aef8e1e7d5a34d848942 -size 3162822 +oid sha256:2a31d33e56d7880f9b475a82f71bab23bd7d2cf4530e52474785b2e1dd72d4b7 +size 3323187 diff --git a/tests/cases/tx2000/tx2000_base_pws_version_24.PWB b/tests/cases/tx2000/tx2000_base_pws_version_24.PWB new file mode 100644 index 00000000..76b3a227 --- /dev/null +++ b/tests/cases/tx2000/tx2000_base_pws_version_24.PWB @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ff55d50c3841bd0f93b2741be000380260350d0334e88815d216643dc074421 +size 3847258 diff --git a/tests/cases/wscc_9/WSCC 9 bus.pwd b/tests/cases/wscc_9/WSCC 9 bus.pwd index a6db60e1..e10c699c 100644 --- a/tests/cases/wscc_9/WSCC 9 bus.pwd +++ b/tests/cases/wscc_9/WSCC 9 bus.pwd @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efd8a02c296f61ee0f629ca4d952ac5368687b29d7a27ed80b5518555298220e -size 15965 +oid sha256:4a1478fe5406886cdba3f9fe157a57787daa8ec3a3d9e12e8d467710face735b +size 16492 diff --git a/tests/cases/wscc_9/WSCC 9 bus_pws_version_24.PWB b/tests/cases/wscc_9/WSCC 9 bus_pws_version_24.PWB new file mode 100644 index 00000000..40210234 --- /dev/null +++ b/tests/cases/wscc_9/WSCC 9 bus_pws_version_24.PWB @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:634fb6c87cc5fb516d22edbfa5c20cdddc92b74e25dcba0e865d719a252f5775 +size 34489 diff --git a/tests/test_saw.py b/tests/test_saw.py index 96d12882..f8a039d2 100644 --- a/tests/test_saw.py +++ b/tests/test_saw.py @@ -478,7 +478,7 @@ def test_3wxformer(self): # Key fields have changed for 3 winding transformers between # versions. - if VERSION in [21, 22, 23]: + if VERSION in [21, 22, 23, 24]: expected = ['BusIdentifier', 'BusIdentifier:1', 'BusIdentifier:2', 'LineCircuit'] elif VERSION == 17: @@ -551,7 +551,7 @@ class IdentifyNumericFieldsTestCase(unittest.TestCase): # noinspection PyMethodMayBeStatic def test_correct(self): # Intentionally make the fields out of alphabetical order. - if VERSION in [21, 22, 23]: + if VERSION in [21, 22, 23, 24]: fields = ['LineStatus', 'LockOut', 'LineR', 'LineX', 'BusNum'] expected = np.array([False, False, True, True, True]) elif VERSION == 17: @@ -745,6 +745,29 @@ def test_get_jacobian_full(self): self.assertIsInstance(self.saw.get_jacobian(full=True), np.ndarray) +class GetGMatrixTestCase(unittest.TestCase): + """Test get_gmatrix function.""" + + @classmethod + def setUpClass(cls) -> None: + cls.saw = SAW(PATH_14, early_bind=True) + + @classmethod + def tearDownClass(cls) -> None: + # noinspection PyUnresolvedReferences + cls.saw.exit() + + def test_get_gmatrix_default(self): + """It should return a scipy csr_matrix. + """ + self.assertIsInstance(self.saw.get_gmatrix(), csr_matrix) + + def test_get_gmatrix_full(self): + """It should return a numpy array of full matrix. + """ + self.assertIsInstance(self.saw.get_gmatrix(full=True), np.ndarray) + + class ChangeToTemperatureTestCase(unittest.TestCase): """Test change_to_temperature function.""" @@ -2460,23 +2483,23 @@ def test_create_if_not_found(self): self.assertFalse(saw_14.CreateIfNotFound) def test_program_information(self): + # Program Information introduced in version 21. + if VERSION < 21: + with self.assertLogs(logger=saw_14.log, level='WARN'): + self.assertFalse(saw_14.ProgramInformation) + return + # Check returned values are variant arrays result = saw_14.ProgramInformation - result_list = list(result) # convert tuple of tuples to list of tuples + self.assertIsInstance(saw_14.ProgramInformation, tuple) - # Checking first entry of each tuple - values = ['version', 'addons', 'executable'] - k = 0 - first_element = map(lambda x: x[0], result_list) - for i in first_element: - self.assertEqual(i, values[k]) - k = k + 1 + # Get the first element of each sub-tuple + first_elements = [item[0] for item in result] - # Program Information introduced in version 21. - if VERSION < 21: - self.assertFalse(saw_14.ProgramInformation) - else: - self.assertIsInstance(saw_14.ProgramInformation, tuple) + # Check that the expected values are present at the start. + # The property may return more data in future versions. + expected_values = ['version', 'addons', 'executable'] + self.assertListEqual(first_elements[:len(expected_values)], expected_values) ######################################################################## @@ -2562,16 +2585,6 @@ def test_close_with_wrong_name(self): 'Cannot find Oneline'): saw_14.CloseOneline("A file that cannot be found") - def test_close_with_invalid_identifier(self): - """Close the oneline diagram with invalid identifier should - raise a PowerWorld Error - """ - saw_14.OpenOneLine(PATH_14_PWD) - with self.assertRaisesRegex(PowerWorldError, - 'invalid identifier character'): - saw_14.CloseOneline(PATH_14_PWD) - - ######################################################################## # Misc tests ######################################################################## From b43329c6afd1977180a91616aa0c62e3c87a90a1 Mon Sep 17 00:00:00 2001 From: Wyatt Lowery Date: Tue, 6 Jan 2026 01:46:22 -0600 Subject: [PATCH 2/2] Cleanup --- temp_test.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 temp_test.py diff --git a/temp_test.py b/temp_test.py deleted file mode 100644 index 79ca98ca..00000000 --- a/temp_test.py +++ /dev/null @@ -1,18 +0,0 @@ -CASE_PATH = r"C:\Users\wyatt\OneDrive - Texas A&M University\Research\Cases\Hawaii\Hawaii40_20231026.pwb" - -from esa import SAW - - -saw = SAW(FileName=CASE_PATH) - -bus_data = saw.get_power_flow_results('bus') - -G = saw.get_gmatrix() - -saw.SolvePowerFlow() - -# NOTE Jacobean returns error until after power flow is solved -J = saw.get_jacobian() - -print("Jacobian Matrix:", J.shape, J.nnz) -print("G Matrix:", G.shape, G.nnz) \ No newline at end of file