Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@ repos:
# Run the formatter.
- id: ruff-format
types_or: [ python, pyi, jupyter ]

# clean notebooks with nb-clean
- repo: https://github.com/srstevenson/nb-clean
rev: 4.0.1
hooks:
- id: nb-clean
args:
- --preserve-cell-outputs
- --
20 changes: 19 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,35 @@

## env

To install Python environment, use:

```sh
uv venv env --python 3.14
uv venv env --python 3.12
source env/bin/activate
uv pip install pip
```

> [!Note]
> Python 3.12 currently needed for `matlabengine`.

## packages

### Requirements

```sh
uv pip install pru
pru -r requirements-all.txt
```

### Matlab

Optionally, if you want to test matlab compatibility, you can install `matlabengine`

```shell
uv pip install matlabengine
```


## Install in development mode

```shell
Expand All @@ -24,6 +41,7 @@ uv pip install -e ."[dev]"

```shell
pytest -n auto -rA --lf -c pyproject.toml --cov-report term-missing --cov=matpowercaseframes tests/
pytest --lf -rA -c pyproject.toml --cov-report term-missing --cov=matpowercaseframes --nbmake
```

## Pre-Commit
Expand Down
163 changes: 147 additions & 16 deletions matpowercaseframes/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,20 +555,11 @@ def _read_data(
path_no_ext, ext = os.path.splitext(path)

if ext == ".m":
# read `.m` file
if load_case_engine is None:
# read with matpower parser
self._read_matpower(
filepath=path,
allow_any_keys=allow_any_keys,
)
else:
# read using loadcase
mpc = load_case_engine.loadcase(path)
self._read_oct2py_struct(
struct=mpc,
allow_any_keys=allow_any_keys,
)
self._read_m_file(
path,
load_case_engine=load_case_engine,
allow_any_keys=allow_any_keys,
)
elif ext == ".xlsx":
# read `.xlsx` file
self._read_excel(
Expand Down Expand Up @@ -683,6 +674,31 @@ def _get_path(path):
message = f"Can't find data at {os.path.abspath(path)}"
raise FileNotFoundError(message)

def _read_m_file(self, path, load_case_engine=None, allow_any_keys=False):
# read `.m` file
if load_case_engine is None:
# read with matpower parser
self._read_matpower(
filepath=path,
allow_any_keys=allow_any_keys,
)
else:
# read using loadcase
mpc = load_case_engine.loadcase(path)

# detect engine type
engine_type = _detect_engine(load_case_engine)
if engine_type == "matlab":
self._read_matlab_struct(
struct=mpc,
allow_any_keys=allow_any_keys,
)
else:
self._read_oct2py_struct(
struct=mpc,
allow_any_keys=allow_any_keys,
)

def _read_matpower(self, filepath, allow_any_keys=False):
"""
Read and parse a MATPOWER file.
Expand Down Expand Up @@ -752,6 +768,42 @@ def _read_oct2py_struct(self, struct, allow_any_keys=False):

return None

def _read_matlab_struct(self, struct, allow_any_keys=False):
"""
Read data from a MATLAB engine struct.

MATLAB engine returns cell arrays (e.g. bus_name) as flat lists of str,
unlike oct2py which wraps each entry in a list.

Args:
struct (matlab.engine struct / dict-like):
Data returned by matlab.engine loadcase.
allow_any_keys (bool):
Whether to allow any keys beyond ATTRIBUTES.
"""
self.name = ""

for attribute, list_ in struct.items():
if attribute not in ATTRIBUTES and not allow_any_keys:
continue

if attribute in ATTRIBUTES_INFO:
value = list_
elif attribute in ATTRIBUTES_NAME:
# MATLAB engine cell array of strings comes as a flat list of str
value = pd.Index(list(list_), name=attribute)
elif attribute in ["reserves"]:
dfs = reserves_data_to_dataframes(list_)
value = ReservesFrames(dfs)
else: # bus, branch, gen, gencost, dcline, dclinecost
arr = np.atleast_2d(np.array(list_))
n_cols = arr.shape[1]
value = self._get_dataframe(attribute, arr, n_cols)

self.set_attribute(attribute, value)

return None

def _read_numpy_struct(self, array, allow_any_keys=False):
"""
Read data from a structured NumPy array.
Expand Down Expand Up @@ -1259,6 +1311,7 @@ def to_dict(self):
value = getattr(self, attribute)
if attribute in ATTRIBUTES_NAME:
# NOTE: must be in 2D Cell or 2D np.array
# ("bus_name", "branch_name", "gen_name")
data[attribute] = np.atleast_2d(value.values).T
elif isinstance(value, pd.DataFrame):
data[attribute] = value.values.tolist()
Expand All @@ -1268,15 +1321,69 @@ def to_dict(self):
data[attribute] = value
return data

def to_mpc(self):
def to_matlab(self):
"""
Convert the CaseFrames data into a MATLAB-compatible dictionary using
matlab.double for array fields.


Returns:
dict: Dictionary with MATLAB-compatible values (matlab.double for arrays).


Raises:
ImportError: If matlab.engine is not available.
"""
try:
import matlab
except ImportError:
raise ImportError(
"matlab.engine is required for MATLAB backend. "
"Install MATLAB Engine API for Python."
)

data = {
"version": None,
"baseMVA": None,
}
for attribute in self._attributes:
value = getattr(self, attribute)
if attribute in ATTRIBUTES_NAME:
# NOTE: matlab does not support [N, 1] cell array.
# See: https://github.com/mathworks/matlab-engine-for-python/issues/61
continue
elif isinstance(value, pd.DataFrame):
data[attribute] = matlab.double(value.values.tolist())
elif isinstance(value, DataFramesStruct):
# TODO: test with case with structs
# convert nested structs (e.g. reserves) as plain dict
data[attribute] = value.to_dict()
else:
data[attribute] = value
return data

def to_mpc(self, backend=None):
"""
Convert the CaseFrames data into a format compatible with MATPOWER.


Args:
backend (str | None):
Backend format. None or 'dict' returns plain dict,
'matlab' returns matlab.double arrays for matrix fields.


Returns:
dict: MATPOWER-compatible dictionary with data.
"""
return self.to_dict()
if backend is None:
return self.to_dict()
elif backend == "octave":
raise NotImplementedError("Octave backend is not implemented yet.")
elif backend == "matlab":
return self.to_matlab()
elif backend == "numpy":
raise NotImplementedError("NumPy backend is not implemented yet.")

def to_schema(self, path, prefix="", suffix=""):
"""
Expand Down Expand Up @@ -1306,6 +1413,30 @@ def to_schema(self, path, prefix="", suffix=""):
self.to_csv(path, prefix=prefix, suffix=suffix)


def _detect_engine(m):
"""Detect engine type from instance."""
try:
from oct2py import Oct2Py

if isinstance(m, Oct2Py):
return "octave"
except ImportError:
pass

try:
import matlab.engine

if isinstance(m, matlab.engine.MatlabEngine):
return "matlab"
except ImportError:
pass

raise ValueError(
f"Unknown engine type: {type(m)}. Expected Oct2Py or"
" matlab.engine.MatlabEngine."
)


def reserves_data_to_dataframes(reserves):
"""
Convert all mpc.reserves struct data to DataFrames.
Expand Down
29 changes: 14 additions & 15 deletions notebooks/compare_load.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -12,7 +12,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -26,7 +26,7 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -39,7 +39,7 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": null,
"metadata": {},
"outputs": [
{
Expand Down Expand Up @@ -73,7 +73,7 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": null,
"metadata": {},
"outputs": [
{
Expand Down Expand Up @@ -217,7 +217,7 @@
"[3 rows x 21 columns]"
]
},
"execution_count": 5,
"execution_count": null,
"metadata": {},
"output_type": "execute_result"
}
Expand All @@ -228,7 +228,7 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
Expand Down Expand Up @@ -271,7 +271,7 @@
},
{
"cell_type": "code",
"execution_count": 7,
"execution_count": null,
"metadata": {},
"outputs": [
{
Expand All @@ -280,7 +280,7 @@
"(dtype('<f8'), dtype('float64'))"
]
},
"execution_count": 7,
"execution_count": null,
"metadata": {},
"output_type": "execute_result"
}
Expand All @@ -291,7 +291,7 @@
},
{
"cell_type": "code",
"execution_count": 8,
"execution_count": null,
"metadata": {},
"outputs": [
{
Expand Down Expand Up @@ -343,7 +343,7 @@
" dtype: object)"
]
},
"execution_count": 8,
"execution_count": null,
"metadata": {},
"output_type": "execute_result"
}
Expand All @@ -354,7 +354,7 @@
},
{
"cell_type": "code",
"execution_count": 9,
"execution_count": null,
"metadata": {},
"outputs": [
{
Expand Down Expand Up @@ -384,7 +384,7 @@
"dtype: object"
]
},
"execution_count": 9,
"execution_count": null,
"metadata": {},
"output_type": "execute_result"
}
Expand Down Expand Up @@ -417,8 +417,7 @@
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.2"
"pygments_lexer": "ipython3"
}
},
"nbformat": 4,
Expand Down
Loading