From 2857e4396ebdd4d6f670598826bc0de981d04c5e Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Mon, 30 Oct 2023 17:53:50 +0100 Subject: [PATCH] Add voltage unbalance calculation (#142) Closes #136 Add method `res_voltage_unbalance` to 3-phase buses that calculates the voltage unbalance factor. Note that this is a method, not a property, so that we can support more standards in the future by passing arguments to this method. Voltage unbalance does not make sense for non 3-phase buses thus we raise an error with a helpful message. I also fixed the symmetrical to phasor converters to preserve the shape of the input array. --- .pre-commit-config.yaml | 2 +- .vscode/extensions.json | 3 +- .vscode/settings.json | 6 ++++ doc/Bibliography.bib | 11 ++++++++ doc/Changelog.md | 2 ++ doc/usage/Extras.md | 24 ++++++++++++++++ roseau/load_flow/converters.py | 12 +++++--- roseau/load_flow/models/buses.py | 25 ++++++++++++++++- roseau/load_flow/models/tests/test_buses.py | 31 +++++++++++++++++++++ roseau/load_flow/tests/test_converters.py | 28 +++++++++---------- 10 files changed, 123 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f2862b5..d6165d96 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - id: blacken-docs entry: bash -c "blacken-docs -l 90 $(find doc/ -name '*.md')" args: [-l 90] - additional_dependencies: [black==23.9.1] # keep in sync with black above + additional_dependencies: [black==23.10.1] # keep in sync with black above - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.0.3 hooks: diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5226822d..07f32b7a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,10 @@ { "recommendations": [ "charliermarsh.ruff", + "esbenp.prettier-vscode", "ms-python.black-formatter", "ms-python.python", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", ], "unwantedRecommendations": [ "ms-python.flake8", // We use ruff diff --git a/.vscode/settings.json b/.vscode/settings.json index 8071bd53..5c4f517a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,4 +20,10 @@ "source.organizeImports.ruff": "explicit", }, }, + // Prettier + "prettier.printWidth": 120, + "[markdown][yaml][html][css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + } } diff --git a/doc/Bibliography.bib b/doc/Bibliography.bib index 85fd2b2a..7e9aa0b3 100644 --- a/doc/Bibliography.bib +++ b/doc/Bibliography.bib @@ -29,3 +29,14 @@ @misc{wiki:Method_Of_Image_Charges url = {http://en.wikipedia.org/w/index.php?title=Method\%20of\%20image\%20charges&oldid=1152888135}, note = "[Online; accessed 25-August-2023]" } + +@inproceedings{Girigoudar_2019, + author = {Girigoudar, Kshitij and Molzahn, Daniel K. and Roald, Line A.}, + booktitle = {2019 North American Power Symposium (NAPS)}, + title = {On The Relationships Among Different Voltage Unbalance Definitions}, + year = {2019}, + volume = {}, + number = {}, + pages = {1-6}, + doi = {10.1109/NAPS46351.2019.9000231} +} diff --git a/doc/Changelog.md b/doc/Changelog.md index 88d090ab..deb17056 100644 --- a/doc/Changelog.md +++ b/doc/Changelog.md @@ -4,6 +4,8 @@ **In development** +- {gh-pr}`142` {gh-issue}`136` Add `Bus.res_voltage_unbalance()` method to get the Voltage Unbalance + Factor (VUF) as defined by the IEC standard IEC 61000-3-14. - {gh-pr}`141` {gh-issue}`137` Add `ElectricalNetwork.to_graph()` to get a `networkx.Graph` object representing the electrical network for graph theory studies. Install with the `"graph"` extra to get _networkx_. diff --git a/doc/usage/Extras.md b/doc/usage/Extras.md index 768df904..434d3fa9 100644 --- a/doc/usage/Extras.md +++ b/doc/usage/Extras.md @@ -135,3 +135,27 @@ the module documentation for more details. An enumeration of available conductor types can be found in the {mod}`roseau.load_flow.utils.types` module. + +## Voltage unbalance + +It is possible to calculate the voltage unbalance due to asymmetric operation. There are many +definitions of voltage unbalance (see {cite:p}`Girigoudar_2019`). In `roseau-load-flow`, you can +use the {meth}`~roseau.load_flow.models.Bus.res_voltage_unbalance` method on a 3-phase bus to get +the Voltage Unbalance Factor (VUF) as per the IEC definition: + +```{math} +VUF = \frac{|V_n|}{|V_p|} * 100 (\%) +``` + +Where $V_n$ is the negative-sequence voltage and $V_p$ is the positive-sequence voltage. + +```{note} +Other definitions of voltage unbalance could be added in the future. If you need a specific +definition, please open an issue on the GitHub repository. +``` + +## Bibliography + +```{bibliography} +:filter: docname in docnames +``` diff --git a/roseau/load_flow/converters.py b/roseau/load_flow/converters.py index 32417120..6f5c57d0 100644 --- a/roseau/load_flow/converters.py +++ b/roseau/load_flow/converters.py @@ -30,14 +30,18 @@ def phasor_to_sym(v_abc: Sequence[complex]) -> np.ndarray[complex]: """Compute the symmetrical components `(0, +, -)` from the phasor components `(a, b, c)`.""" - v_012 = _A_INV @ np.asarray(v_abc).reshape((3, 1)) - return v_012 + v_abc_array = np.asarray(v_abc) + orig_shape = v_abc_array.shape + v_012 = _A_INV @ v_abc_array.reshape((3, 1)) + return v_012.reshape(orig_shape) def sym_to_phasor(v_012: Sequence[complex]) -> np.ndarray[complex]: """Compute the phasor components `(a, b, c)` from the symmetrical components `(0, +, -)`.""" - v_abc = A @ np.asarray(v_012).reshape((3, 1)) - return v_abc + v_012_array = np.asarray(v_012) + orig_shape = v_012_array.shape + v_abc = A @ v_012_array.reshape((3, 1)) + return v_abc.reshape(orig_shape) def series_phasor_to_sym(s_abc: pd.Series) -> pd.Series: diff --git a/roseau/load_flow/models/buses.py b/roseau/load_flow/models/buses.py index 252dbdf4..928ff1e1 100644 --- a/roseau/load_flow/models/buses.py +++ b/roseau/load_flow/models/buses.py @@ -7,7 +7,7 @@ from shapely import Point from typing_extensions import Self -from roseau.load_flow.converters import calculate_voltage_phases, calculate_voltages +from roseau.load_flow.converters import calculate_voltage_phases, calculate_voltages, phasor_to_sym from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode from roseau.load_flow.models.core import Element from roseau.load_flow.typing import Id, JsonDict @@ -279,6 +279,29 @@ def get_connected_buses(self) -> Iterator[Id]: to_add = set(element._connected_elements).difference(visited) remaining.update(to_add) + @ureg_wraps("percent", (None,), strict=False) + def res_voltage_unbalance(self) -> Q_[float]: + """Calculate the voltage unbalance on this bus according to the IEC definition. + + Voltage Unbalance Factor: + + :math:`VUF = \\frac{|V_n|}{|V_p|} * 100 (\\%)` + + Where :math:`V_n` is the negative-sequence voltage and :math:`V_p` is the positive-sequence + voltage. + """ + # https://std.iec.ch/terms/terms.nsf/3385f156e728849bc1256e8c00278ad2/771c5188e62fade5c125793a0043f2a5?OpenDocument + if self.phases not in {"abc", "abcn"}: + msg = f"Voltage unbalance is only available for 3-phases buses, bus {self.id!r} has phases {self.phases!r}" + logger.error(msg) + raise RoseauLoadFlowException(msg, code=RoseauLoadFlowExceptionCode.BAD_PHASE) + # We use the potentials here which is equivalent to using the "line to neutral" voltages as + # defined by the standard. The standard also has this note: + # NOTE 1 Phase-to-phase voltages may also be used instead of line to neutral voltages. + potentials = self._res_potentials_getter(warning=True) + _, vp, vn = phasor_to_sym(potentials[:3]) # (0, +, -) + return abs(vn) / abs(vp) * 100 + # # Json Mixin interface # diff --git a/roseau/load_flow/models/tests/test_buses.py b/roseau/load_flow/models/tests/test_buses.py index bbe0ec2f..5fd92ff9 100644 --- a/roseau/load_flow/models/tests/test_buses.py +++ b/roseau/load_flow/models/tests/test_buses.py @@ -324,3 +324,34 @@ def test_get_connected_buses(): assert sorted(mvb.get_connected_buses()) == mv_bus_ids for lvb in lv_buses: assert sorted(lvb.get_connected_buses()) == lv_bus_ids + + +def test_res_voltage_unbalance(): + bus = Bus("b3", phases="abc") + + va = 230 + 0j + vb = 230 * np.exp(4j * np.pi / 3) + vc = 230 * np.exp(2j * np.pi / 3) + + # Balanced system + bus._res_potentials = np.array([va, vb, vc]) + assert np.isclose(bus.res_voltage_unbalance().magnitude, 0) + + # Unbalanced system + bus._res_potentials = np.array([va, vb, vb]) + assert np.isclose(bus.res_voltage_unbalance().magnitude, 100) + + # With neutral + bus = Bus("b3n", phases="abcn") + bus._res_potentials = np.array([va, vb, vc, 0]) + assert np.isclose(bus.res_voltage_unbalance().magnitude, 0) + bus._res_potentials = np.array([va, vb, vb, 0]) + assert np.isclose(bus.res_voltage_unbalance().magnitude, 100) + + # Non 3-phase bus + bus = Bus("b1", phases="an") + bus._res_potentials = np.array([va, 0]) + with pytest.raises(RoseauLoadFlowException) as e: + bus.res_voltage_unbalance() + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_PHASE + assert e.value.msg == "Voltage unbalance is only available for 3-phases buses, bus 'b1' has phases 'an'" diff --git a/roseau/load_flow/tests/test_converters.py b/roseau/load_flow/tests/test_converters.py index 929dacc6..06b262c6 100644 --- a/roseau/load_flow/tests/test_converters.py +++ b/roseau/load_flow/tests/test_converters.py @@ -13,23 +13,23 @@ def test_phasor_to_sym(): vc = 230 * np.e ** (1j * 2 * np.pi / 3) # Test balanced direct system: positive sequence - expected = np.array([[0], [230], [0]], dtype=complex) + expected = np.array([0, 230, 0], dtype=complex) assert np.allclose(phasor_to_sym([va, vb, vc]), expected) # Also test numpy array input with different shapes assert np.allclose(phasor_to_sym(np.array([va, vb, vc])), expected) - assert np.allclose(phasor_to_sym(np.array([[va], [vb], [vc]])), expected) + assert np.allclose(phasor_to_sym(np.array([[va], [vb], [vc]])), expected.reshape((3, 1))) # Test balanced indirect system: negative sequence - expected = np.array([[0], [0], [230]], dtype=complex) + expected = np.array([0, 0, 230], dtype=complex) assert np.allclose(phasor_to_sym([va, vc, vb]), expected) # Test unbalanced system: zero sequence - expected = np.array([[230], [0], [0]], dtype=complex) + expected = np.array([230, 0, 0], dtype=complex) assert np.allclose(phasor_to_sym([va, va, va]), expected) # Test unbalanced system: general case va = 200 + 0j - expected = np.array([[10 * np.e ** (1j * np.pi)], [220], [10 * np.e ** (1j * np.pi)]], dtype=complex) + expected = np.array([10 * np.exp(1j * np.pi), 220, 10 * np.exp(1j * np.pi)], dtype=complex) assert np.allclose(phasor_to_sym([va, vb, vc]), expected) @@ -40,23 +40,23 @@ def test_sym_to_phasor(): vc = 230 * np.e ** (1j * 2 * np.pi / 3) # Test balanced direct system: positive sequence - expected = np.array([[va], [vb], [vc]], dtype=complex) + expected = np.array([va, vb, vc], dtype=complex) assert np.allclose(sym_to_phasor([0, va, 0]), expected) # Also test numpy array input with different shapes assert np.allclose(sym_to_phasor(np.array([0, va, 0])), expected) - assert np.allclose(sym_to_phasor(np.array([[0], [va], [0]])), expected) + assert np.allclose(sym_to_phasor(np.array([[0], [va], [0]])), expected.reshape((3, 1))) # Test balanced indirect system: negative sequence - expected = np.array([[va], [vc], [vb]], dtype=complex) + expected = np.array([va, vc, vb], dtype=complex) assert np.allclose(sym_to_phasor([0, 0, va]), expected) # Test unbalanced system: zero sequence - expected = np.array([[va], [va], [va]], dtype=complex) + expected = np.array([va, va, va], dtype=complex) assert np.allclose(sym_to_phasor([va, 0, 0]), expected) # Test unbalanced system: general case va = 200 + 0j - expected = np.array([[va], [vb], [vc]], dtype=complex) + expected = np.array([va, vb, vc], dtype=complex) assert np.allclose(sym_to_phasor([10 * np.e ** (1j * np.pi), 220, 10 * np.e ** (1j * np.pi)]), expected) @@ -66,17 +66,17 @@ def test_phasor_sym_roundtrip(): vc = 230 * np.e ** (1j * 2 * np.pi / 3) # Test balanced direct system: positive sequence - assert np.allclose(sym_to_phasor(phasor_to_sym([va, vb, vc])), np.array([[va], [vb], [vc]])) + assert np.allclose(sym_to_phasor(phasor_to_sym([va, vb, vc])), np.array([va, vb, vc])) # Test balanced indirect system: negative sequence - assert np.allclose(sym_to_phasor(phasor_to_sym([va, vc, vb])), np.array([[va], [vc], [vb]])) + assert np.allclose(sym_to_phasor(phasor_to_sym([va, vc, vb])), np.array([va, vc, vb])) # Test unbalanced system: zero sequence - assert np.allclose(sym_to_phasor(phasor_to_sym([va, va, va])), np.array([[va], [va], [va]])) + assert np.allclose(sym_to_phasor(phasor_to_sym([va, va, va])), np.array([va, va, va])) # Test unbalanced system: general case va = 200 + 0j - assert np.allclose(sym_to_phasor(phasor_to_sym([va, vb, vc])), np.array([[va], [vb], [vc]])) + assert np.allclose(sym_to_phasor(phasor_to_sym([va, vb, vc])), np.array([va, vb, vc])) def test_series_phasor_to_sym():