diff --git a/.github/workflows/run-test-cases.yml b/.github/workflows/run-test-cases.yml new file mode 100644 index 0000000..1667001 --- /dev/null +++ b/.github/workflows/run-test-cases.yml @@ -0,0 +1,73 @@ +# This workflow run all the cases found in the pypower folder + +name: Run test cases + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + ubuntu-test-matrix: + + strategy: + matrix: + # See https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories + version: [22.04,24.04,latest] + + runs-on: ubuntu-${{matrix.version}} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.12' + + - name: Install dependencies + run: python3 -m pip install --upgrade pip -r requirements.txt + + - name: Run test cases + run: python3 tests/test_cases.py + + - name: Upload artifacts on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: | + tests/ + + macos-test-matrix: + + strategy: + matrix: + # See https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories + version: [13,14,15,latest] + + runs-on: macos-${{matrix.version}} + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.12' + + - name: Install dependencies + run: python3 -m pip install --upgrade pip -r requirements.txt + + - name: Run test cases + run: python3 tests/test_cases.py + + - name: Upload artifacts on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: | + tests/ diff --git a/.gitignore b/.gitignore index 900916c..6da2c6c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ *~ build/ dist/ -PYPOWER.egg-info/ +*.egg-info/ doc/api/ doc/.build/* -/venv/ +*venv/ +__pycache__/ diff --git a/README.rst b/README.rst index a0267ce..ea85fc6 100644 --- a/README.rst +++ b/README.rst @@ -36,41 +36,57 @@ Prerequisites PYPOWER depends upon these prerequisites on the level of the operating system: -* Python_ >= 3.5 +* Python_ >= 3.10 Virtual Environment =================== PYPOWER is recommended to be installed into a virtual environment:: - $ python3.8 -m venv venv # Or any supported Python version + python3.12 -m venv venv # Or any supported Python version Dependencies ============ PYPOWER depends upon NumPy, SciPy and PyRLU which can be installed as follows:: - $ venv/bin/python -m pip install -r requirements.txt + venv/bin/python -m pip install -r requirements.txt Installation ============ The recommended way of installing PYPOWER is using pip_:: - $ venv/bin/python -m pip install PYPOWER + venv/bin/python -m pip install PYPOWER Alternatively, `download `_ and unpack the tarball and install:: - $ tar zxf PYPOWER-5.x.y.tar.gz - $ venv/bin/python setup.py install + tar zxf PYPOWER-5.x.y.tar.gz + venv/bin/python setup.py install Testing ======= PYPOWER can be tested locally using the same tooling as on Travis CI:: - $ venv/bin/python -m tox -e py27,py38 # Or any supported Python version + venv/bin/python -m tox -e 3.10,3.11,3.12 # Or any supported Python version + +Case Testing +============ + +The cases in the `pypower` folder can also be tested locally using the command:: + + python3.x -m venv .venv + . .venv/bin/activate + python3 -m pip install -m pip --upgrade -r requirements.txt + python3 tests/test_cases.py + +where `x` is one of the supported python minor version numbers. + +See the `tests/test_cases.py `_ script for additional information on the output files. + +**Note**: this test first runs the `tox` tests in the current environment. Using PYPOWER ============= @@ -78,25 +94,25 @@ Using PYPOWER Installing PYPOWER creates ``pf`` and ``opf`` commands. To list the command options:: - $ venv/bin/pf -h + venv/bin/pf -h or:: - $ venv/bin/opf -h + venv/bin/opf -h PYPOWER includes a selection of test cases. For example, to run a power flow on the IEEE 14 bus test case:: - $ venv/bin/pf -c case14 + venv/bin/pf -c case14 Alternatively, the path to a PYPOWER case data file can be specified:: - $ venv/bin/pf /path/to/case14.py + venv/bin/pf /path/to/case14.py The ``opf`` command has the same calling syntax. For example, to solve an OPF for the IEEE Reliability Test System and write the solved case to file:: - $ venv/bin/opf -c case24_ieee_rts --solvedcase=rtsout.py + venv/bin/opf -c case24_ieee_rts --solvedcase=rtsout.py For further information please refer to https://rwl.github.io/PYPOWER/ and the `API documentation`_. @@ -142,6 +158,7 @@ Links * PSAT_ by Federico Milano * OpenDSS_ from EPRI * GridLAB-D_ from PNNL +* Arras-Energy_ from `LF Energy `_ * PyCIM_ .. _Python: http://www.python.org @@ -160,3 +177,5 @@ Links .. _TESP: https://tesp.readthedocs.io .. _Oct2PYPOWER: https://github.com/rwl/oct2pypower .. _matpower.app: https://matpower.app +.. _Arras-Energy: https://arras.energy/ + diff --git a/pypower/cpf_p_jac.py b/pypower/cpf_p_jac.py index 025f819..54a6d73 100644 --- a/pypower/cpf_p_jac.py +++ b/pypower/cpf_p_jac.py @@ -1,7 +1,7 @@ '''Computes partial derivatives of CPF parameterization function. ''' -from numpy import r_, zeros, angle +from numpy import r_, zeros, angle, array def cpf_p_jac(parameterization, z, V, lam, Vprv, lamprv, pv, pq): @@ -33,4 +33,4 @@ def cpf_p_jac(parameterization, z, V, lam, Vprv, lamprv, pv, pq): dP_dV = z[r_[pv, pq, nb+pq]] dP_dlam = z[2 * nb] - return dP_dV, dP_dlam \ No newline at end of file + return dP_dV, array([dP_dlam]) \ No newline at end of file diff --git a/pypower/opf_hessfcn.py b/pypower/opf_hessfcn.py index de842f5..c940e09 100644 --- a/pypower/opf_hessfcn.py +++ b/pypower/opf_hessfcn.py @@ -103,7 +103,7 @@ def opf_hessfcn(x, lmbda, om, Ybus, Yf, Yt, ppopt, il=None, cost_mult=1.0): ipolp = find(pcost[:, MODEL] == POLYNOMIAL) d2f_dPg2[ipolp] = \ baseMVA**2 * polycost(pcost[ipolp, :], Pg[ipolp] * baseMVA, 2) - if any(qcost): ## Qg is not free + if qcost.any(): ## Qg is not free ipolq = find(qcost[:, MODEL] == POLYNOMIAL) d2f_dQg2[ipolq] = \ baseMVA**2 * polycost(qcost[ipolq, :], Qg[ipolq] * baseMVA, 2) diff --git a/pypower/polycost.py b/pypower/polycost.py index fd57359..bb2ee83 100644 --- a/pypower/polycost.py +++ b/pypower/polycost.py @@ -30,7 +30,7 @@ def polycost(gencost, Pg, der=0): """ if gencost.size == 0: #User has a purely linear piecewise problem, exit early with empty array - return [] + return zeros(0) if any(gencost[:, MODEL] == PW_LINEAR): sys.stderr.write('polycost: all costs must be polynomial\n') diff --git a/requirements.txt b/requirements.txt index 1b966bd..84500ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,22 @@ +# only exact versions allowed (<, >, <=, >= not supported by testing) appdirs==1.4.4 -distlib==0.3.1 -filelock==3.0.12 +cachetools==6.0.0 +chardet==5.2.0 +colorama==0.4.6 +distlib==0.3.9 +filelock==3.18.0 numpy==2.2.6 -packaging==20.9 +packaging==25.0 pip==21.1 -pluggy==0.13.1 -py==1.10.0 -pyparsing==2.4.7 +platformdirs==4.3.8 +pluggy==1.6.0 +py==1.11.0 +pyparsing==3.2.3 +pyproject-api==1.9.1 pyrlu==0.2.1 -scipy==1.6.1 -setuptools==65.5.1 -six==1.15.0 +scipy==1.15.3 +setuptools==80.9.0 +six==1.17.0 toml==0.10.2 -tox==3.23.0 -virtualenv==20.4.3 +tox==4.26.0 +virtualenv==20.31.2 diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..f9f321d --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,3 @@ +*.json +*.out +*.err diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100644 index 0000000..dfd767f --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,185 @@ +"""PyPOWER Module testing + +This script run all the cases in the pypower folder for both PF and OPF solutions. +All results are stored in the `case*_pf.out` and `case*_opf.out` unless there is an +exception generated by the solver itself, in which case the traceback is stored +in the file `case*.err`. If the PF fails, the OPF is not attempted. + +Case files with the string "target" in them will be run as CPF problems instead of +PF/OPF problems. + +The output JSON file includes both the problem and solution, as well as supporting +information about the test environment. + +Before running the test cases, the script also runs the tox tests in the pypower +folder. + +To run the test procedure, use the following command: + +~~~ +pip install . +python3 tests/test_cases.py +~~~ + +The following exit codes are used: + +* `0`: all tests passed ok +* `1`: one or more test cases failed +* `2`: one of more unit tests failed +""" + +import os +import sys +import importlib +import json +import numpy as np +import traceback +import pkg_resources +import types + +EXEDIR,EXENAME = os.path.split(sys.argv[0]) +os.chdir(EXEDIR if EXEDIR else ".") + +version = pkg_resources.require('pypower')[0].version + +MODULEDIR = ".." +TESTDIR = f"{MODULEDIR}/pypower/t" +CASEDIR = f"{MODULEDIR}/pypower" +IGNORE = [ # modules that should be ignored by modules() + "appdirs", + "cachetools", + "chardet", + "colorama", + "distlib", + "filelock", + "packaging", + "platformdirs", + "pluggy", + "py", + "pyparsing", + "pyproject-api", + "setuptools", + "six", + "toml", + "tox", + "virtualenv", +] + +sys.path.extend([MODULEDIR,CASEDIR]) +from pypower.api import runpf, runcpf, runopf, ppoption, opf_model + +tested = 0 +failed = 0 + +def modules(): + """Get pypower runtime required modules in appjson format""" + with open(f"{MODULEDIR}/requirements.txt","r") as fh: + reqs = dict([x.strip().split("==",1) for x in fh.readlines() + if not x.strip().startswith("#")]) + return {x:{"version":y} for x,y in reqs.items() if x not in IGNORE} + +class NumpyEncoder(json.JSONEncoder): + """JSON encoder for numpy arrays""" + def default(self, obj): + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, opf_model): + return {x:(y.tolist() if hasattr(y,"tolist") else y) for x,y in obj.user_data.items()} + if isinstance(obj, complex): + return f"{obj.real:g}{obj.imag:+g}j" + return super().default(obj) + +def delete(files): + """Delete files if found""" + for file in files: + try: + os.remove(file) + except: + pass + +def savejson(casedata,fh,result=None,**kwargs): + """Save casedata as a JSON application file""" + if "indent" not in kwargs: + kwargs["indent"] = 4 + json.dump({ + "application": "pypower", + "version": version, + "modules" : modules(), + "problem" : casedata, + "solution" : result, + },fh,cls=NumpyEncoder,**kwargs) + +# first run tox testing of pypower +print(f"Testing all pypower v{version} tests in {TESTDIR}...") +if os.system(f"{os.environ['_']} {TESTDIR}/test_pypower.py") != 0: + print(f"ERROR [{EXENAME}]: pypower unit tests failed--case testing cannot continue",file= sys.stderr) + exit(2) + +# now run pypower cases +print(f"Testing all pypower v{version} cases in {CASEDIR}...") +for case in os.listdir(CASEDIR): + + # only test files that start with "case" and end in ".py" + if case.startswith("case") and case.endswith(".py"): + name = os.path.splitext(case)[0] + module = importlib.__import__(name) + try: + + # clean-up any old output files + delete([f"{name}_pf.out",f"{name}_opf.out",f"{name}.err"]) + + # only test modules that contain a function by the same name per convention + if hasattr(module,name): + + tested += 1 + print(f"Solving {case} problems",end="... ",flush=True,file=sys.stdout) + + # get case data from file + casedata = getattr(module,name)() + ppopt = ppoption(VERBOSE=0,OUT_ALL=0) + + if "target" in name: + + print("CPF",end="... ",flush=True,file=sys.stdout) + result = runcpf(f"{CASEDIR}/{name}.py".replace("target",""),casedata,ppopt) + savejson({"basecase":result,"target":casedata},open(f"{name}.json","w"),result) + print(result,file=open(f"{name}_cpf.out","w")) + assert result[1] == 1, "runcpf failed" + + else: + + print("PF",end="... ",flush=True,file=sys.stdout) + result = runpf(casedata,ppopt) + savejson(casedata,open(f"{name}.json","w"),result) + print(result,file=open(f"{name}_pf.out","w")) + assert result[1] == 1, "runpf failed" + + if "gencost" in casedata: + + print("OPF",end="... ",flush=True,file=sys.stdout) + result = runopf(casedata,ppopt) + savejson(casedata,open(f"{name}.json","w"),result) + print(result,file=open(f"{name}_opf.out","w")) + assert result["success"], "runopf failed" + + print("ok.",file=sys.stdout) + + except Exception as err: + + print(f"ERROR [{EXENAME}]: {name} -- {err}",file=sys.stderr) + e_type,e_value,e_trace = sys.exc_info() + e_file = f"{name}.err" + savejson(casedata,open(f"{name}.json","w"), + result={ + "exception":e_type.__name__, + "value":str(e_value), + "traceback":e_file + }) + with open(e_file,"w") as fh: + trace = '\n'.join(traceback.format_tb(e_trace)) + print(f"EXCEPTION [{name}]: {e_type.__name__} -- {e_value}\n\n{trace}",file=fh) + failed += 1 + +print(f"Testing completed: {tested=}, {failed=}") + +exit(1 if failed > 0 else 0) diff --git a/tox.ini b/tox.ini index ee4b670..ddba470 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,8 @@ [tox] -envlist = py37,py38,py39 +env_list = + 3.10 + 3.11 + 3.12 [testenv] deps =