diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7273267c..38ffe32d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,3 +38,4 @@ repos: hooks: - id: prettier args: ["--print-width", "120"] + exclude: ^.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json index fe97411f..8071bd53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,24 +3,21 @@ "jupyter.notebookFileRoot": "${workspaceFolder}", "notebook.formatOnSave.enabled": true, "notebook.codeActionsOnSave": { - "source.organizeImports.ruff": true + "source.organizeImports.ruff": "explicit", }, // Python "python.analysis.diagnosticSeverityOverrides": { "reportInvalidStringEscapeSequence": "warning", "reportImportCycles": "warning", - "reportUnusedImport": "warning" + "reportUnusedImport": "warning", }, - "python.formatting.provider": "none", - "python.testing.pytestArgs": [], - "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports.ruff": true - } - } + "source.organizeImports.ruff": "explicit", + }, + }, } diff --git a/doc/Changelog.md b/doc/Changelog.md index 19f8c3af..845e0ce2 100644 --- a/doc/Changelog.md +++ b/doc/Changelog.md @@ -4,6 +4,14 @@ **In development** +- {gh-pr}`138` Add network constraints for analysis of the results. + - Buses can define minimum and maximum voltages. Use `bus.res_violated` to see if the bus has + over- or under-voltage. + - Lines can define a maximum current. Use `line.res_violated` to see if the loading of any of the + line's cables is too high. + - Transformers can define a maximum power. Use `transformer.res_violated` to see if the transformer + loading is too high. + - The new fields also appear in the data frames of the network. - {gh-pr}`133` {gh-issue}`126` Add Qmin and Qmax limits of flexible parameters. - {gh-pr}`132` {gh-issue}`101` Document extra utilities including converters and constants. - {gh-pr}`131` {gh-issue}`127` Improve the documentation of the flexible loads. diff --git a/doc/usage/Getting_Started.md b/doc/usage/Getting_Started.md index be252290..00a93256 100644 --- a/doc/usage/Getting_Started.md +++ b/doc/usage/Getting_Started.md @@ -9,9 +9,10 @@ In this tutorial you will learn how to: 1. [Create a simple electrical network with one source and one load](gs-creating-network); 2. [Solve a load flow](gs-solving-load-flow); 3. [Get the results of the load flow](gs-getting-results); -4. [Update the elements of the network](gs-updating-elements); -5. [Save the network and the results to the disk for later analysis](gs-saving-network); -6. [Load the saved network and the results from the disk](gs-loading-network). +4. [Analyze the results](gs-analysis-and-violations); +5. [Update the elements of the network](gs-updating-elements); +6. [Save the network and the results to the disk for later analysis](gs-saving-network); +7. [Load the saved network and the results from the disk](gs-loading-network). (gs-creating-network)= @@ -70,9 +71,17 @@ It leads to the following code >>> import numpy as np ... from roseau.load_flow import * +>>> # Nominal phase-to-neutral voltage +... un = 400 / np.sqrt(3) # In Volts + +>>> # Optional network limits (for results analysis only) +... u_min = 0.9 * un # V +... u_max = 1.1 * un # V +... i_max = 500.0 # A + >>> # Create two buses -... source_bus = Bus(id="sb", phases="abcn") -... load_bus = Bus(id="lb", phases="abcn") +... source_bus = Bus(id="sb", phases="abcn", min_voltage=u_min, max_voltage=u_max) +... load_bus = Bus(id="lb", phases="abcn", min_voltage=u_min, max_voltage=u_max) >>> # Define the reference of potentials to be the neutral of the source bus ... ground = Ground(id="gnd") @@ -81,17 +90,20 @@ It leads to the following code ... ground.connect(source_bus, phase="n") >>> # Create a LV source at the first bus -... # Volts (phase-to-neutral because the source is connected to the neutral) -... un = 400 / np.sqrt(3) -... source_voltages = [un, un * np.exp(-2j * np.pi / 3), un * np.exp(2j * np.pi / 3)] -... vs = VoltageSource(id="vs", bus=source_bus, voltages=source_voltages) +... # (phase-to-neutral voltage because the source is connected to the neutral) +... source_voltages = un * np.exp([0, -2j * np.pi / 3, 2j * np.pi / 3]) +... vs = VoltageSource( +... id="vs", bus=source_bus, voltages=source_voltages +... ) # phases="abcn" inferred from the bus >>> # Add a load at the second bus ... load = PowerLoad(id="load", bus=load_bus, powers=[10e3 + 0j, 10e3, 10e3]) # VA >>> # Add a LV line between the source bus and the load bus ... # R = 0.1 Ohm/km, X = 0 -... lp = LineParameters("lp", z_line=(0.1 + 0.0j) * np.eye(4, dtype=complex)) +... lp = LineParameters( +... "lp", z_line=(0.1 + 0.0j) * np.eye(4, dtype=complex), max_current=i_max +... ) ... line = Line(id="line", bus1=source_bus, bus2=load_bus, parameters=lp, length=2.0) ``` @@ -208,15 +220,16 @@ The results returned by the `res_` properties are also `Quantity` objects. The available results depend on the type of element. The following table summarizes the available results for each element type: -| Element type | Available results | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `Bus` | `res_potentials`, `res_voltages` | -| `Line` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages`, `res_series_power_losses`, `res_shunt_power_losses`, `res_power_losses` | -| `Transformer`, `Switch` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages` | -| `ImpedanceLoad`, `CurrentLoad`, `PowerLoad` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages`, `res_flexible_powers`⁎ | -| `VoltageSource` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages` | -| `Ground` | `res_potential` | -| `PotentialRef` | `res_current` _(Always zero for a successful load flow)_ | +| Element type | Available results | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Bus` | `res_potentials`, `res_voltages`, `res_violated` | +| `Line` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages`, `res_series_power_losses`, `res_shunt_power_losses`, `res_power_losses`, `res_violated` | +| `Transformer` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages`, `res_violated` | +| `Switch` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages` | +| `ImpedanceLoad`, `CurrentLoad`, `PowerLoad` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages`, `res_flexible_powers`⁎ | +| `VoltageSource` | `res_currents`, `res_powers`, `res_potentials`, `res_voltages` | +| `Ground` | `res_potential` | +| `PotentialRef` | `res_current` _(Always zero for a successful load flow)_ | ⁎: `res_flexible_powers` is only available for flexible loads (`PowerLoad`s with `flexible_params`). You'll see an example on the usage of flexible loads in the _Flexible Loads_ section. @@ -242,7 +255,8 @@ array([0.22192818, 0.22192818, 0.22192818]) ```{important} Everywhere in `roseau-load-flow`, the `voltages` of an element depend on the element's `phases`. -Voltages of elements connected in a *Star (wye)* configuration (elements that have a neutral connection indicated by the presence of the `'n'` char in their `phases` attribute) are the +Voltages of elements connected in a *Star (wye)* configuration (elements that have a neutral +connection indicated by the presence of the `'n'` char in their `phases` attribute) are the **phase-to-neutral** voltages. Voltages of elements connected in a *Delta* configuration (elements that do not have a neutral connection indicated by the absence of the `'n'` char from their `phases` attribute) are the **phase-to-phase** voltages. This is true for *input* voltages, such @@ -276,9 +290,13 @@ The results can also be retrieved for the entire network using `res_` properties Available results for the network are: - `res_buses`: Buses potentials indexed by _(bus id, phase)_ -- `res_buses_voltages`: Buses voltages indexed by _(bus id, voltage phase)_ +- `res_buses_voltages`: Buses voltages and voltage limits indexed by _(bus id, voltage phase)_ - `res_branches`: Branches currents, powers, and potentials indexed by _(branch id, phase)_ -- `res_lines`: Lines currents, powers, potentials, series losses, series currents indexed by _(line id, phase)_ +- `res_transformers`: Transformers currents, powers, potentials, and power limits indexed by + _(transformer id, phase)_ +- `res_lines`: Lines currents, powers, potentials, series losses, series currents, and current + limits indexed by _(line id, phase)_ +- `res_switches`: Switches currents, powers, and potentials indexed by _(switch id, phase)_ - `res_loads`: Loads currents, powers, and potentials indexed by _(load id, phase)_ - `res_loads_voltages`: Loads voltages indexed by _(load id, voltage phase)_ - `res_loads_flexible_powers`: Loads flexible powers (only for flexible loads) indexed by @@ -317,14 +335,14 @@ All the following tables are rounded to 2 decimals to be properly displayed. >>> en.res_buses_voltages ``` -| bus_id | phase | voltage | -| :----- | :---- | -------------: | -| sb | an | 230.94+0j | -| sb | bn | -115.47-200j | -| sb | cn | -115.47+200j | -| lb | an | 221.93+0j | -| lb | bn | -110.96-192.2j | -| lb | cn | -110.96+192.2j | +| bus_id | phase | voltage | min_voltage | max_voltage | violated | +| :----- | :---- | -------------: | ----------: | ----------: | :------- | +| sb | an | 230.94+0j | 207.846 | 254.034 | False | +| sb | bn | -115.47-200j | 207.846 | 254.034 | False | +| sb | cn | -115.47+200j | 207.846 | 254.034 | False | +| lb | an | 221.93-0j | 207.846 | 254.034 | False | +| lb | bn | -110.96-192.2j | 207.846 | 254.034 | False | +| lb | cn | -110.96+192.2j | 207.846 | 254.034 | False | ```pycon >>> en.res_branches @@ -341,12 +359,26 @@ All the following tables are rounded to 2 decimals to be properly displayed. >>> en.res_lines ``` -| branch_id | phase | current1 | current2 | power1 | power2 | potential1 | potential2 | series_losses | series_current | -| :-------- | :---- | ------------: | -----------: | ----------: | --------: | -----------: | -------------: | ------------: | -------------: | -| line | a | 45.06+0j | -45.06-0j | 10406.07-0j | -10000+0j | 230.94+0j | 221.93-0j | 406.07-0j | 45.06+0j | -| line | b | -22.53-39.02j | 22.53+39.02j | 10406.07+0j | -10000-0j | -115.47-200j | -110.96-192.2j | 406.07-0j | -22.53-39.02j | -| line | c | -22.53+39.02j | 22.53-39.02j | 10406.07-0j | -10000+0j | -115.47+200j | -110.96+192.2j | 406.07+0j | -22.53+39.02j | -| line | n | 0j | -0j | -0j | -0j | 0j | -0j | -0j | -0+0j | +| line_id | phase | current1 | current2 | power1 | power2 | potential1 | potential2 | series_losses | series_current | max_current | violated | +| :------ | :---- | ------------: | -----------: | ----------: | --------: | -----------: | -------------: | ------------: | -------------: | ----------: | :------- | +| line | a | 45.06-0j | -45.06+0j | 10406.07+0j | -10000-0j | 230.94+0j | 221.93+0j | 406.07-0j | 45.06-0j | 500 | False | +| line | b | -22.53-39.02j | 22.53+39.02j | 10406.07+0j | -10000-0j | -115.47-200j | -110.96-192.2j | 406.07-0j | -22.53-39.02j | 500 | False | +| line | c | -22.53+39.02j | 22.53-39.02j | 10406.07-0j | -10000+0j | -115.47+200j | -110.96+192.2j | 406.07+0j | -22.53+39.02j | 500 | False | +| line | n | -0-0j | 0j | -0+0j | -0j | 0j | 0j | -0j | -0-0j | 500 | False | + +```pycon +>>> en.res_transformers +``` + +| transformer_id | phase | current1 | current2 | power1 | power2 | potential1 | potential2 | max_power | violated | +| -------------- | ----- | -------- | -------- | ------ | ------ | ---------- | ---------- | --------- | -------- | + +```pycon +>>> en.res_switches +``` + +| switch_id | phase | current1 | current2 | power1 | power2 | potential1 | potential2 | +| --------- | ----- | -------- | -------- | ------ | ------ | ---------- | ---------- | ```pycon >>> en.res_loads @@ -373,12 +405,12 @@ All the following tables are rounded to 2 decimals to be properly displayed. >>> en.res_sources ``` -| source_id | phase | current | power | potential | -| :-------- | :---- | -----------: | ------------: | -----------: | -| vs | a | -45.06-0j | -10406.07+0j) | 230.94+0j | -| vs | b | 22.53+39.02j | -10406.07-0j) | -115.47-200j | -| vs | c | 22.53-39.02j | -10406.07+0j) | -115.47+200j | -| vs | n | 0j | 0j | 0j | +| source_id | phase | current | power | potential | +| :-------- | :---- | -----------: | -----------: | -----------: | +| vs | a | -45.06-0j | -10406.07+0j | 230.94+0j | +| vs | b | 22.53+39.02j | -10406.07-0j | -115.47-200j | +| vs | c | 22.53-39.02j | -10406.07+0j | -115.47+200j | +| vs | n | 0j | 0j | 0j | ```pycon >>> en.res_grounds @@ -400,33 +432,71 @@ Using the `transform` method of data frames, the results can easily be converted to magnitude and angle values. ```pycon ->>> en.res_buses_voltages.transform([np.abs, np.angle]) +>>> en.res_buses_voltages["voltage"].transform([np.abs, np.angle]) ``` -| bus_id | phase | ('voltage', 'absolute') | ('voltage', 'angle') | -| :----- | :---- | ----------------------: | -------------------: | -| sb | an | 230.94 | 0 | -| sb | bn | 230.94 | -2.0944 | -| sb | cn | 230.94 | 2.0944 | -| lb | an | 221.928 | 2.89102e-19 | -| lb | bn | 221.928 | -2.0944 | -| lb | cn | 221.928 | 2.0944 | +| bus_id | phase | absolute | angle | +| :----- | :---- | -------: | ----------: | +| sb | an | 230.94 | 0 | +| sb | bn | 230.94 | -2.0944 | +| sb | cn | 230.94 | 2.0944 | +| lb | an | 221.928 | 2.89102e-19 | +| lb | bn | 221.928 | -2.0944 | +| lb | cn | 221.928 | 2.0944 | Or, if you prefer degrees: ```pycon >>> import functools as ft -... en.res_buses_voltages.transform([np.abs, ft.partial(np.angle, deg=True)]) +... en.res_buses_voltages["voltage"].transform([np.abs, ft.partial(np.angle, deg=True)]) ``` -| bus_id | phase | ('voltage', 'absolute') | ('voltage', 'angle') | -| :----- | :---- | ----------------------: | -------------------: | -| sb | an | 230.94 | 0 | -| sb | bn | 230.94 | -120 | -| sb | cn | 230.94 | 120 | -| lb | an | 221.928 | 1.65643e-17 | -| lb | bn | 221.928 | -120 | -| lb | cn | 221.928 | 120 | +| bus_id | phase | absolute | angle | +| :----- | :---- | -------: | ----------: | +| sb | an | 230.94 | 0 | +| sb | bn | 230.94 | -120 | +| sb | cn | 230.94 | 120 | +| lb | an | 221.928 | 1.65643e-17 | +| lb | bn | 221.928 | -120 | +| lb | cn | 221.928 | 120 | + +(gs-analysis-and-violations)= + +## Analyzing the results and detecting violations + +In the example network above, `min_voltage` and `max_voltage` arguments were passed to the `Bus` +constructor and `max_current` was passed to the `LineParameters` constructor. These arguments +define the limits of the network that can be used to check if the network is in a valid state +or not. Note that these limits have no effect on the load flow calculation. + +If you set `min_voltage` or `max_voltage` on a bus, the `res_violated` property will tell you if +the voltage limits are violated or not at this bus. Here, the voltage limits are not violated. + +```pycon +>>> load_bus.res_violated +False +``` + +Similarly, if you set `max_current` on a line, the `res_violated` property will tell you if the +current loading of the line in any phase exceeds the limit. Here, the current limit is not violated. + +```pycon +>>> line.res_violated +False +``` + +The power limit of the transformer can be defined using the `max_power` argument of the +`TransformerParameters`. Transformers also have a `res_violated` property that indicates if the +power loading of the transformer exceeds the limit. + +The data frame results on the electrical network also include a `violated` column that indicates if +the limits are violated or not for the corresponding element. + +```{tip} +You can use the {meth}`Bus.propagate_limits() ` method to +propagate the limits from a bus to its neighboring buses, that is, buses on the same side of a +transformer. +``` (gs-updating-elements)= diff --git a/roseau/load_flow/exceptions.py b/roseau/load_flow/exceptions.py index 31747d16..31d8ed0d 100644 --- a/roseau/load_flow/exceptions.py +++ b/roseau/load_flow/exceptions.py @@ -24,6 +24,7 @@ class RoseauLoadFlowExceptionCode(Enum): BAD_BUS_ID = auto() BAD_BUS_TYPE = auto() BAD_POTENTIALS_SIZE = auto() + BAD_VOLTAGES = auto() BAD_VOLTAGES_SIZE = auto() BAD_SHORT_CIRCUIT = auto() diff --git a/roseau/load_flow/io/dict.py b/roseau/load_flow/io/dict.py index e1b481d3..8db7ac4f 100644 --- a/roseau/load_flow/io/dict.py +++ b/roseau/load_flow/io/dict.py @@ -132,15 +132,15 @@ def network_from_dict( return buses, branches_dict, loads, sources, grounds, potential_refs -def network_to_dict(en: "ElectricalNetwork", include_geometry: bool) -> JsonDict: +def network_to_dict(en: "ElectricalNetwork", *, _lf_only: bool) -> JsonDict: """Return a dictionary of the current network data. Args: en: The electrical network. - include_geometry: - If False, the geometry will not be added to the network dictionary. + _lf_only: + Internal argument, please do not use. Returns: The created dictionary. @@ -155,7 +155,7 @@ def network_to_dict(en: "ElectricalNetwork", include_geometry: bool) -> JsonDict sources: list[JsonDict] = [] short_circuits: list[JsonDict] = [] for bus in en.buses.values(): - buses.append(bus.to_dict(include_geometry=include_geometry)) + buses.append(bus.to_dict(_lf_only=_lf_only)) for element in bus._connected_elements: if isinstance(element, AbstractLoad): assert element.bus is bus @@ -171,7 +171,7 @@ def network_to_dict(en: "ElectricalNetwork", include_geometry: bool) -> JsonDict lines_params_dict: dict[Id, LineParameters] = {} transformers_params_dict: dict[Id, TransformerParameters] = {} for branch in en.branches.values(): - branches.append(branch.to_dict(include_geometry=include_geometry)) + branches.append(branch.to_dict(_lf_only=_lf_only)) if isinstance(branch, Line): params_id = branch.parameters.id if params_id in lines_params_dict and branch.parameters != lines_params_dict[params_id]: @@ -192,13 +192,13 @@ def network_to_dict(en: "ElectricalNetwork", include_geometry: bool) -> JsonDict # Line parameters line_params: list[JsonDict] = [] for lp in lines_params_dict.values(): - line_params.append(lp.to_dict()) + line_params.append(lp.to_dict(_lf_only=_lf_only)) line_params.sort(key=lambda x: x["id"]) # Always keep the same order # Transformer parameters transformer_params: list[JsonDict] = [] for tp in transformers_params_dict.values(): - transformer_params.append(tp.to_dict()) + transformer_params.append(tp.to_dict(_lf_only=_lf_only)) transformer_params.sort(key=lambda x: x["id"]) # Always keep the same order res = { diff --git a/roseau/load_flow/io/tests/test_dict.py b/roseau/load_flow/io/tests/test_dict.py index cc9a1057..2420586f 100644 --- a/roseau/load_flow/io/tests/test_dict.py +++ b/roseau/load_flow/io/tests/test_dict.py @@ -23,8 +23,8 @@ def test_to_dict(): ground = Ground("ground") vn = 400 / np.sqrt(3) voltages = [vn, vn * np.exp(-2 / 3 * np.pi * 1j), vn * np.exp(2 / 3 * np.pi * 1j)] - source_bus = Bus(id="source", phases="abcn", geometry=Point(0.0, 0.0)) - load_bus = Bus(id="load bus", phases="abcn", geometry=Point(0.0, 1.0)) + source_bus = Bus(id="source", phases="abcn", geometry=Point(0.0, 0.0), min_voltage=0.9 * vn) + load_bus = Bus(id="load bus", phases="abcn", geometry=Point(0.0, 1.0), max_voltage=1.1 * vn) ground.connect(load_bus) p_ref = PotentialRef("pref", element=ground) vs = VoltageSource("vs", source_bus, phases="abcn", voltages=voltages) @@ -44,6 +44,8 @@ def test_to_dict(): grounds=[ground], potential_refs=[p_ref], ) + + # Same id, different line parameters -> fail with pytest.raises(RoseauLoadFlowException) as e: en.to_dict() assert "There are multiple line parameters with id 'test'" in e.value.msg @@ -52,17 +54,28 @@ def test_to_dict(): # Same id, same line parameters -> ok lp2 = LineParameters("test", z_line=np.eye(4, dtype=complex), y_shunt=np.eye(4, dtype=complex)) line2.parameters = lp2 + en.to_dict() + + # Dict content + line2.parameters = lp1 + lp1.max_current = 1000 res = en.to_dict() assert "geometry" in res["buses"][0] assert "geometry" in res["buses"][1] assert "geometry" in res["branches"][0] assert "geometry" in res["branches"][1] + assert np.isclose(res["buses"][0]["min_voltage"], 0.9 * vn) + assert np.isclose(res["buses"][1]["max_voltage"], 1.1 * vn) + assert np.isclose(res["lines_params"][0]["max_current"], 1000) - res = en.to_dict(include_geometry=False) + res = en.to_dict(_lf_only=True) assert "geometry" not in res["buses"][0] assert "geometry" not in res["buses"][1] assert "geometry" not in res["branches"][0] assert "geometry" not in res["branches"][1] + assert "min_voltage" not in res["buses"][0] + assert "max_voltage" not in res["buses"][1] + assert "max_current" not in res["lines_params"][0] # Same id, different transformer parameters -> fail ground = Ground("ground") @@ -93,6 +106,8 @@ def test_to_dict(): grounds=[ground], potential_refs=[p_ref], ) + + # Same id, different transformer parameters -> fail with pytest.raises(RoseauLoadFlowException) as e: en.to_dict() assert "There are multiple transformer parameters with id 't'" in e.value.msg @@ -103,17 +118,24 @@ def test_to_dict(): "t", type="Dyn11", uhv=20000, ulv=400, sn=160 * 1e3, p0=460, i0=2.3 / 100, psc=2350, vsc=4 / 100 ) transformer2.parameters = tp2 + en.to_dict() + + # Dict content + transformer2.parameters = tp1 + tp1.max_power = 180_000 res = en.to_dict() assert "geometry" in res["buses"][0] assert "geometry" in res["buses"][1] assert "geometry" in res["branches"][0] assert "geometry" in res["branches"][1] + assert np.isclose(res["transformers_params"][0]["max_power"], 180_000) - res = en.to_dict(include_geometry=False) + res = en.to_dict(_lf_only=True) assert "geometry" not in res["buses"][0] assert "geometry" not in res["buses"][1] assert "geometry" not in res["branches"][0] assert "geometry" not in res["branches"][1] + assert "max_power" not in res["transformers_params"][0] def test_v0_to_v1_converter(monkeypatch): diff --git a/roseau/load_flow/models/branches.py b/roseau/load_flow/models/branches.py index c9206f6e..08459e75 100644 --- a/roseau/load_flow/models/branches.py +++ b/roseau/load_flow/models/branches.py @@ -126,7 +126,7 @@ def res_voltages(self) -> tuple[Q_[np.ndarray], Q_[np.ndarray]]: def from_dict(cls, data: JsonDict) -> Self: return cls(**data) # not used anymore - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: res = { "id": self.id, "type": self.branch_type, @@ -135,7 +135,7 @@ def to_dict(self, include_geometry: bool = True) -> JsonDict: "bus1": self.bus1.id, "bus2": self.bus2.id, } - if self.geometry is not None and include_geometry: + if not _lf_only and self.geometry is not None: res["geometry"] = self.geometry.__geo_interface__ return res diff --git a/roseau/load_flow/models/buses.py b/roseau/load_flow/models/buses.py index 604ce662..8fead19a 100644 --- a/roseau/load_flow/models/buses.py +++ b/roseau/load_flow/models/buses.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Optional import numpy as np +import pandas as pd from shapely import Point from typing_extensions import Self @@ -40,6 +41,8 @@ def __init__( phases: str, geometry: Optional[Point] = None, potentials: Optional[Sequence[complex]] = None, + min_voltage: Optional[float] = None, + max_voltage: Optional[float] = None, **kwargs: Any, ) -> None: """Bus constructor. @@ -60,8 +63,13 @@ def __init__( potentials: An optional list of initial potentials of each phase of the bus. - ground: - The ground of the bus. + min_voltage: + An optional minimum voltage of the bus (V). It is not used in the load flow. + It must be a phase-neutral voltage if the bus has a neutral, phase-phase otherwise. + + max_voltage: + An optional maximum voltage of the bus (V). It is not used in the load flow. + It must be a phase-neutral voltage if the bus has a neutral, phase-phase otherwise. """ super().__init__(id, **kwargs) self._check_phases(id, phases=phases) @@ -70,6 +78,10 @@ def __init__( potentials = [0] * len(phases) self.potentials = potentials self.geometry = geometry + self._min_voltage: Optional[float] = None + self._max_voltage: Optional[float] = None + self.min_voltage = min_voltage + self.max_voltage = max_voltage self._res_potentials: Optional[np.ndarray] = None self._short_circuits: list[dict[str, Any]] = [] @@ -127,6 +139,124 @@ def _get_potentials_of(self, phases: str, warning: bool) -> np.ndarray: potentials = self._res_potentials_getter(warning) return np.array([potentials[self.phases.index(p)] for p in phases]) + @property + def min_voltage(self) -> Optional[Q_[float]]: + """The minimum voltage of the bus (V) if it is set.""" + return None if self._min_voltage is None else Q_(self._min_voltage, "V") + + @min_voltage.setter + @ureg_wraps(None, (None, "V"), strict=False) + def min_voltage(self, value: Optional[float]) -> None: + if value is not None and self._max_voltage is not None and value > self._max_voltage: + msg = ( + f"Cannot set min voltage of bus {self.id!r} to {value} V as it is higher than its " + f"max voltage ({self._max_voltage} V)." + ) + logger.error(msg) + raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_VOLTAGES) + if pd.isna(value): + value = None + self._min_voltage = value + + @property + def max_voltage(self) -> Optional[Q_[float]]: + """The maximum voltage of the bus (V) if it is set.""" + return None if self._max_voltage is None else Q_(self._max_voltage, "V") + + @max_voltage.setter + @ureg_wraps(None, (None, "V"), strict=False) + def max_voltage(self, value: Optional[float]) -> None: + if value is not None and self._min_voltage is not None and value < self._min_voltage: + msg = ( + f"Cannot set max voltage of bus {self.id!r} to {value} V as it is lower than its " + f"min voltage ({self._min_voltage} V)." + ) + logger.error(msg) + raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_VOLTAGES) + if pd.isna(value): + value = None + self._max_voltage = value + + @property + def res_violated(self) -> Optional[bool]: + """Whether the bus has voltage limits violations. + + Returns ``None`` if the bus has no voltage limits are not set. + """ + if self._min_voltage is None and self._max_voltage is None: + return None + voltages = abs(self._res_voltages_getter(warning=True)) + if self._min_voltage is None: + assert self._max_voltage is not None + return float(max(voltages)) > self._max_voltage + elif self._max_voltage is None: + return float(min(voltages)) < self._min_voltage + else: + return float(min(voltages)) < self._min_voltage or float(max(voltages)) > self._max_voltage + + def propagate_limits(self, force: bool = False) -> None: + """Propagate the voltage limits to neighbor buses. + + Neighbor buses here refers to buses connected to this bus through lines or switches. This + ensures that these voltage limits are only applied to buses with the same voltage level. If + a bus is connected to this bus through a transformer, the voltage limits are not propagated + to that bus. + + If this bus does not define any voltage limits, calling this method will unset the limits + of the neighbor buses. + + Args: + force: + If ``False`` (default), an exception is raised if connected buses already have + limits different from this bus. If ``True``, the limits are propagated even if + connected buses have different limits. + """ + from roseau.load_flow.models.lines import Line, Switch + + buses: set[Bus] = set() + visited: set[Element] = set() + remaining = set(self._connected_elements) + + while remaining: + branch = remaining.pop() + visited.add(branch) + if not isinstance(branch, (Line, Switch)): + continue + for element in branch._connected_elements: + if not isinstance(element, Bus) or element is self or element in buses: + continue + buses.add(element) + to_add = set(element._connected_elements).difference(visited) + remaining.update(to_add) + if not ( + force + or self._min_voltage is None + or element._min_voltage is None + or np.isclose(element._min_voltage, self._min_voltage) + ): + msg = ( + f"Cannot propagate the minimum voltage ({self._min_voltage} V) of bus {self.id!r} " + f"to bus {element.id!r} with different minimum voltage ({element._min_voltage} V)." + ) + logger.error(msg) + raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_VOLTAGES) + if not ( + force + or self._max_voltage is None + or element._max_voltage is None + or np.isclose(element._max_voltage, self._max_voltage) + ): + msg = ( + f"Cannot propagate the maximum voltage ({self._max_voltage} V) of bus {self.id!r} " + f"to bus {element.id!r} with different maximum voltage ({element._max_voltage} V)." + ) + logger.error(msg) + raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_VOLTAGES) + + for bus in buses: + bus._min_voltage = self._min_voltage + bus._max_voltage = self._max_voltage + # # Json Mixin interface # @@ -136,14 +266,26 @@ def from_dict(cls, data: JsonDict) -> Self: potentials = data.get("potentials") if potentials is not None: potentials = [complex(v[0], v[1]) for v in potentials] - return cls(id=data["id"], phases=data["phases"], geometry=geometry, potentials=potentials) - - def to_dict(self, include_geometry: bool = True) -> JsonDict: + return cls( + id=data["id"], + phases=data["phases"], + geometry=geometry, + potentials=potentials, + min_voltage=data.get("min_voltage"), + max_voltage=data.get("max_voltage"), + ) + + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: res = {"id": self.id, "phases": self.phases} if not np.allclose(self.potentials, 0): res["potentials"] = [[v.real, v.imag] for v in self._potentials] - if self.geometry is not None and include_geometry: - res["geometry"] = self.geometry.__geo_interface__ + if not _lf_only: + if self.geometry is not None: + res["geometry"] = self.geometry.__geo_interface__ + if self.min_voltage is not None: + res["min_voltage"] = self.min_voltage.magnitude + if self.max_voltage is not None: + res["max_voltage"] = self.max_voltage.magnitude return res def results_from_dict(self, data: JsonDict) -> None: @@ -204,6 +346,6 @@ def short_circuits(self) -> list[dict[str, Any]]: """Return the list of short-circuits of this bus.""" return self._short_circuits[:] # return a copy as users should not modify the list directly - def clear_short_circuits(self): + def clear_short_circuits(self) -> None: """Remove the short-circuits.""" self._short_circuits = [] diff --git a/roseau/load_flow/models/grounds.py b/roseau/load_flow/models/grounds.py index bbaf0cb1..ca982213 100644 --- a/roseau/load_flow/models/grounds.py +++ b/roseau/load_flow/models/grounds.py @@ -1,14 +1,16 @@ import logging -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional from typing_extensions import Self from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode -from roseau.load_flow.models.buses import Bus from roseau.load_flow.models.core import Element from roseau.load_flow.typing import Id, JsonDict from roseau.load_flow.units import Q_, ureg_wraps +if TYPE_CHECKING: + from roseau.load_flow.models.buses import Bus + logger = logging.getLogger(__name__) @@ -65,7 +67,7 @@ def connected_buses(self) -> dict[Id, str]: """The bus ID and phase of the buses connected to this ground.""" return self._connected_buses.copy() # copy so that the user does not change it - def connect(self, bus: Bus, phase: str = "n") -> None: + def connect(self, bus: "Bus", phase: str = "n") -> None: """Connect the ground to a bus on the given phase. Args: @@ -97,7 +99,7 @@ def from_dict(cls, data: JsonDict) -> Self: self._connected_buses = data["buses"] return self - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: # Shunt lines and potential references will have the ground in their dict not here. return { "id": self.id, diff --git a/roseau/load_flow/models/lines/lines.py b/roseau/load_flow/models/lines/lines.py index 725212b5..36564fd7 100644 --- a/roseau/load_flow/models/lines/lines.py +++ b/roseau/load_flow/models/lines/lines.py @@ -292,6 +292,13 @@ def y_shunt(self) -> Q_[np.ndarray]: """Shunt admittance of the line in Siemens""" return self.parameters._y_shunt * self._length + @property + def max_current(self) -> Optional[Q_[float]]: + """The maximum current loading of the line in A.""" + # Do not add a setter. The user must know that if they change the max_current, it changes + # for all lines that share the parameters. It is better to set it on the parameters. + return self.parameters.max_current + @property def with_shunt(self) -> bool: return self.parameters.with_shunt @@ -369,12 +376,25 @@ def res_power_losses(self) -> Q_[np.ndarray]: """Get the power losses in the line (VA).""" return self._res_power_losses_getter(warning=True) + @property + def res_violated(self) -> Optional[bool]: + """Whether the line current exceeds the maximum current (loading > 100%). + + Returns ``None`` if the maximum current is not set. + """ + i_max = self.parameters._max_current + if i_max is None: + return None + currents1, currents2 = self._res_currents_getter(warning=True) + # True if any phase is overloaded + return float(np.max([abs(currents1), abs(currents2)])) > i_max + # # Json Mixin interface # - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: res = { - **super().to_dict(include_geometry=include_geometry), + **super().to_dict(_lf_only=_lf_only), "length": self._length, "params_id": self.parameters.id, } diff --git a/roseau/load_flow/models/lines/parameters.py b/roseau/load_flow/models/lines/parameters.py index c64731d0..f9c86e68 100644 --- a/roseau/load_flow/models/lines/parameters.py +++ b/roseau/load_flow/models/lines/parameters.py @@ -43,8 +43,10 @@ class LineParameters(Identifiable, JsonMixin): rf"^({_type_re})_({_material_re})_{_section_re}$", flags=re.IGNORECASE ) - @ureg_wraps(None, (None, None, "ohm/km", "S/km"), strict=False) - def __init__(self, id: Id, z_line: np.ndarray, y_shunt: Optional[np.ndarray] = None) -> None: + @ureg_wraps(None, (None, None, "ohm/km", "S/km", "A"), strict=False) + def __init__( + self, id: Id, z_line: np.ndarray, y_shunt: Optional[np.ndarray] = None, max_current: Optional[float] = None + ) -> None: """LineParameters constructor. Args: @@ -56,6 +58,9 @@ def __init__(self, id: Id, z_line: np.ndarray, y_shunt: Optional[np.ndarray] = N y_shunt: The Y matrix of the line (Siemens/km). This field is optional if the line has no shunt part. + + max_current: + An optional maximum current loading of the line (A). It is not used in the load flow. """ super().__init__(id) self._z_line = np.asarray(z_line, dtype=complex) @@ -65,6 +70,7 @@ def __init__(self, id: Id, z_line: np.ndarray, y_shunt: Optional[np.ndarray] = N else: self._with_shunt = not np.allclose(y_shunt, 0) self._y_shunt = np.asarray(y_shunt, dtype=complex) + self.max_current = max_current self._check_matrix() def __eq__(self, other: object) -> bool: @@ -99,9 +105,19 @@ def y_shunt(self) -> Q_[np.ndarray]: def with_shunt(self) -> bool: return self._with_shunt + @property + def max_current(self) -> Optional[Q_[float]]: + """The maximum current loading of the line (A) if it is set.""" + return None if self._max_current is None else Q_(self._max_current, "A") + + @max_current.setter + @ureg_wraps(None, (None, "A"), strict=False) + def max_current(self, value: Optional[float]) -> None: + self._max_current = value + @classmethod @ureg_wraps( - None, (None, None, "ohm/km", "ohm/km", "S/km", "S/km", "ohm/km", "ohm/km", "S/km", "S/km"), strict=False + None, (None, None, "ohm/km", "ohm/km", "S/km", "S/km", "ohm/km", "ohm/km", "S/km", "S/km", "A"), strict=False ) def from_sym( cls, @@ -114,6 +130,7 @@ def from_sym( xpn: Optional[float] = None, bn: Optional[float] = None, bpn: Optional[float] = None, + max_current: Optional[float] = None, ) -> Self: """Create line parameters from a symmetric model. @@ -145,6 +162,9 @@ def from_sym( bpn: Phase to neutral susceptance (siemens/km) + max_current: + An optional maximum current loading of the line (A). It is not used in the load flow. + Returns: The created line parameters. @@ -154,7 +174,7 @@ def from_sym( impedance matrix is not invertible. """ z_line, y_shunt = cls._sym_to_zy(id=id, z0=z0, z1=z1, y0=y0, y1=y1, zn=zn, xpn=xpn, bn=bn, bpn=bpn) - return cls(id=id, z_line=z_line, y_shunt=y_shunt) + return cls(id=id, z_line=z_line, y_shunt=y_shunt, max_current=max_current) @staticmethod def _sym_to_zy( @@ -277,7 +297,7 @@ def _sym_to_zy( return z_line, y_shunt @classmethod - @ureg_wraps(None, (None, None, None, None, None, "mm**2", "mm**2", "m", "m"), strict=False) + @ureg_wraps(None, (None, None, None, None, None, "mm**2", "mm**2", "m", "m", "A"), strict=False) def from_geometry( cls, id: Id, @@ -288,6 +308,7 @@ def from_geometry( section_neutral: float, height: float, external_diameter: float, + max_current: Optional[float] = None, ) -> Self: """Create line parameters from its geometry. @@ -316,6 +337,9 @@ def from_geometry( external_diameter: External diameter of the wire (m). + max_current: + An optional maximum current loading of the line (A). It is not used in the load flow. + Returns: The created line parameters. @@ -332,7 +356,7 @@ def from_geometry( height=height, external_diameter=external_diameter, ) - return cls(id=id, z_line=z_line, y_shunt=y_shunt) + return cls(id=id, z_line=z_line, y_shunt=y_shunt, max_current=max_current) @staticmethod def _geometry_to_zy( @@ -469,13 +493,14 @@ def _geometry_to_zy( return z_line, y_shunt @classmethod - @ureg_wraps(None, (None, None, "mm²", "m", "mm"), strict=False) + @ureg_wraps(None, (None, None, "mm²", "m", "mm", "A"), strict=False) def from_name_lv( cls, name: str, section_neutral: Optional[float] = None, height: Optional[float] = None, external_diameter: Optional[float] = None, + max_current: Optional[float] = None, ) -> Self: """Method to get the electrical parameters of a LV line from its canonical name. Some hypothesis will be made: the section of the neutral is the same as the other sections, the height and @@ -494,6 +519,9 @@ def from_name_lv( external_diameter: External diameter of the wire (mm). If None a default value will be used. + max_current: + An optional maximum current loading of the line (A). It is not used in the load flow. + Returns: The corresponding line parameters. """ @@ -527,16 +555,20 @@ def from_name_lv( section_neutral=section_neutral, height=height, external_diameter=external_diameter, + max_current=max_current, ) @classmethod - def from_name_mv(cls, name: str) -> Self: + def from_name_mv(cls, name: str, max_current: Optional[float] = None) -> Self: """Method to get the electrical parameters of a MV line from its canonical name. Args: name: The name of the line the parameters must be computed. E.g. "U_AL_150". + max_current: + An optional maximum current loading of the line (A). It is not used in the load flow. + Returns: The corresponding line parameters. """ @@ -573,7 +605,7 @@ def from_name_mv(cls, name: str) -> Self: z_line = (r + x * 1j) * np.eye(3, dtype=float) # in ohms/km y_shunt = b * 1j * np.eye(3, dtype=float) # in siemens/km - return cls(name, z_line=z_line, y_shunt=y_shunt) + return cls(name, z_line=z_line, y_shunt=y_shunt, max_current=max_current) # # Json Mixin interface @@ -591,13 +623,15 @@ def from_dict(cls, data: JsonDict) -> Self: """ z_line = np.asarray(data["z_line"][0]) + 1j * np.asarray(data["z_line"][1]) y_shunt = np.asarray(data["y_shunt"][0]) + 1j * np.asarray(data["y_shunt"][1]) if "y_shunt" in data else None - return cls(id=data["id"], z_line=z_line, y_shunt=y_shunt) + return cls(id=data["id"], z_line=z_line, y_shunt=y_shunt, max_current=data.get("max_current")) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: """Return the line parameters information as a dictionary format.""" res = {"id": self.id, "z_line": [self._z_line.real.tolist(), self._z_line.imag.tolist()]} if self.with_shunt: res["y_shunt"] = [self._y_shunt.real.tolist(), self._y_shunt.imag.tolist()] + if not _lf_only and self.max_current is not None: + res["max_current"] = self.max_current.magnitude return res def _results_to_dict(self, warning: bool) -> NoReturn: diff --git a/roseau/load_flow/models/loads/flexible_parameters.py b/roseau/load_flow/models/loads/flexible_parameters.py index 6d2353b2..e7fcb95d 100644 --- a/roseau/load_flow/models/loads/flexible_parameters.py +++ b/roseau/load_flow/models/loads/flexible_parameters.py @@ -312,7 +312,7 @@ def from_dict(cls, data: JsonDict) -> Self: logger.error(msg) raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_CONTROL_TYPE) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: if self.type == "constant": return {"type": "constant"} elif self.type == "p_max_u_production": @@ -427,7 +427,7 @@ def from_dict(cls, data: JsonDict) -> Self: epsilon = data["epsilon"] if "epsilon" in data else cls._DEFAULT_EPSILON return cls(type=data["type"], alpha=alpha, epsilon=epsilon) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: return {"type": self.type, "alpha": self._alpha, "epsilon": self._epsilon} def _results_to_dict(self, warning: bool) -> NoReturn: @@ -957,7 +957,7 @@ def from_dict(cls, data: JsonDict) -> Self: q_max=q_max, ) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: res = { "control_p": self.control_p.to_dict(), "control_q": self.control_q.to_dict(), @@ -1021,7 +1021,7 @@ def _compute_powers( bus = Bus(id="bus", phases="an") vs = VoltageSource(id="source", bus=bus, voltages=[voltages[0]]) PotentialRef(id="pref", element=bus, phase="n") - fp = FlexibleParameter.from_dict(data=self.to_dict(include_geometry=False)) + fp = FlexibleParameter.from_dict(data=self.to_dict(_lf_only=True)) load = PowerLoad(id="load", bus=bus, powers=[power], flexible_params=[fp]) en = ElectricalNetwork.from_element(bus) diff --git a/roseau/load_flow/models/loads/loads.py b/roseau/load_flow/models/loads/loads.py index b64bc7c0..428fc1f5 100644 --- a/roseau/load_flow/models/loads/loads.py +++ b/roseau/load_flow/models/loads/loads.py @@ -324,7 +324,7 @@ def res_flexible_powers(self) -> Q_[np.ndarray]: # # Json Mixin interface # - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: self._raise_disconnected_error() res = { "id": self.id, @@ -396,7 +396,7 @@ def currents(self, value: Sequence[complex]) -> None: self._currents = self._validate_value(value) self._invalidate_network_results() - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: self._raise_disconnected_error() return { "id": self.id, @@ -451,7 +451,7 @@ def impedances(self, impedances: Sequence[complex]) -> None: self._impedances = self._validate_value(impedances) self._invalidate_network_results() - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: self._raise_disconnected_error() return { "id": self.id, diff --git a/roseau/load_flow/models/potential_refs.py b/roseau/load_flow/models/potential_refs.py index deb25c3e..f49f3f6d 100644 --- a/roseau/load_flow/models/potential_refs.py +++ b/roseau/load_flow/models/potential_refs.py @@ -86,7 +86,7 @@ def res_current(self) -> Q_[complex]: def from_dict(cls, data: JsonDict) -> Self: return cls(data["id"], data["element"], phase=data.get("phases")) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: res = {"id": self.id} e = self.element if isinstance(e, Bus): diff --git a/roseau/load_flow/models/sources.py b/roseau/load_flow/models/sources.py index 5da3a7bd..9691a05f 100644 --- a/roseau/load_flow/models/sources.py +++ b/roseau/load_flow/models/sources.py @@ -161,7 +161,7 @@ def from_dict(cls, data: JsonDict) -> Self: voltages = [complex(v[0], v[1]) for v in data["voltages"]] return cls(data["id"], data["bus"], voltages=voltages, phases=data["phases"]) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: self._raise_disconnected_error() return { "id": self.id, diff --git a/roseau/load_flow/models/tests/test_buses.py b/roseau/load_flow/models/tests/test_buses.py index 298d7564..fb05e427 100644 --- a/roseau/load_flow/models/tests/test_buses.py +++ b/roseau/load_flow/models/tests/test_buses.py @@ -1,14 +1,21 @@ import numpy as np +import pandas as pd import pytest from roseau.load_flow import ( + Q_, Bus, ElectricalNetwork, Ground, + Line, + LineParameters, PotentialRef, PowerLoad, RoseauLoadFlowException, RoseauLoadFlowExceptionCode, + Switch, + Transformer, + TransformerParameters, VoltageSource, ) @@ -74,3 +81,203 @@ def test_short_circuit(): bus.add_short_circuit("a", "b") assert "is already connected on bus" in e.value.msg assert e.value.args[1] == RoseauLoadFlowExceptionCode.BAD_SHORT_CIRCUIT + + +def test_voltage_limits(): + # Default values + bus = Bus("bus", phases="abc") + assert bus.min_voltage is None + assert bus.max_voltage is None + + # Passed as arguments + bus = Bus("bus", phases="abc", min_voltage=350, max_voltage=420) + assert bus.min_voltage == Q_(350, "V") + assert bus.max_voltage == Q_(420, "V") + + # Can be set to a real number + bus.min_voltage = 350.0 + bus.max_voltage = 420.0 + assert bus.min_voltage == Q_(350.0, "V") + assert bus.max_voltage == Q_(420.0, "V") + + # Can be reset to None + bus.min_voltage = None + bus.max_voltage = None + assert bus.min_voltage is None + assert bus.max_voltage is None + + # Can be set to a Quantity + bus.min_voltage = Q_(19, "kV") + bus.max_voltage = Q_(21, "kV") + assert bus.min_voltage == Q_(19_000, "V") + assert bus.max_voltage == Q_(21_000, "V") + + # NaNs are converted to None + for na in (np.nan, float("nan"), pd.NA): + bus.min_voltage = na + bus.max_voltage = na + assert bus.min_voltage is None + assert bus.max_voltage is None + + # Bad values + bus.min_voltage = 220 + with pytest.raises(RoseauLoadFlowException) as e: + bus.max_voltage = 200 + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_VOLTAGES + assert e.value.msg == "Cannot set max voltage of bus 'bus' to 200 V as it is lower than its min voltage (220 V)." + bus.max_voltage = 240 + with pytest.raises(RoseauLoadFlowException) as e: + bus.min_voltage = 250 + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_VOLTAGES + assert e.value.msg == "Cannot set min voltage of bus 'bus' to 250 V as it is higher than its max voltage (240 V)." + + +def test_res_violated(): + bus = Bus("bus", phases="abc") + direct_seq = np.exp([0, -2 / 3 * np.pi * 1j, 2 / 3 * np.pi * 1j]) + bus._res_potentials = 230 * direct_seq + + # No limits + assert bus.res_violated is None + + # Only min voltage + bus.min_voltage = 350 + assert bus.res_violated is False + bus.min_voltage = 450 + assert bus.res_violated is True + + # Only max voltage + bus.min_voltage = None + bus.max_voltage = 450 + assert bus.res_violated is False + bus.max_voltage = 350 + assert bus.res_violated is True + + # Both min and max voltage + # min <= v <= max + bus.min_voltage = 350 + bus.max_voltage = 450 + assert bus.res_violated is False + # v < min + bus.min_voltage = 450 + assert bus.res_violated is True + # v > max + bus.min_voltage = 350 + bus.max_voltage = 350 + assert bus.res_violated is True + + +def test_propagate_limits(): # noqa: C901 + b1_mv = Bus("b1_mv", phases="abc") + b2_mv = Bus("b2_mv", phases="abc") + b3_mv = Bus("b3_mv", phases="abc") + b1_lv = Bus("b1_lv", phases="abcn") + b2_lv = Bus("b2_lv", phases="abcn") + + PotentialRef("pref_mv", element=b1_mv) + g = Ground("g") + PotentialRef("pref_lv", element=g) + + lp_mv = LineParameters("lp_mv", z_line=np.eye(3), y_shunt=0.1 * np.eye(3)) + lp_lv = LineParameters("lp_lv", z_line=np.eye(4)) + tp = TransformerParameters.from_catalogue(id="SE_Minera_A0Ak_100kVA", manufacturer="SE") + + Line("l1_mv", b1_mv, b2_mv, length=1.5, parameters=lp_mv, ground=g) + Line("l2_mv", b2_mv, b3_mv, length=2, parameters=lp_mv, ground=g) + Transformer("tr", b3_mv, b1_lv, parameters=tp) + Line("l1_lv", b1_lv, b2_lv, length=1, parameters=lp_lv) + + voltages = 20_000 * np.exp([0, -2 / 3 * np.pi * 1j, 2 / 3 * np.pi * 1j]) + VoltageSource("s_mv", bus=b1_mv, voltages=voltages) + + PowerLoad("pl1_mv", bus=b2_mv, powers=[10e3, 10e3, 10e3]) + PowerLoad("pl2_mv", bus=b3_mv, powers=[10e3, 10e3, 10e3]) + PowerLoad("pl1_lv", bus=b1_lv, powers=[1e3, 1e3, 1e3]) + PowerLoad("pl2_lv", bus=b2_lv, powers=[1e3, 1e3, 1e3]) + + # All buses have None as min and max voltage + for bus in (b1_mv, b2_mv, b3_mv, b1_lv, b2_lv): + assert bus.min_voltage is None + assert bus.max_voltage is None + + # Set min and max voltage of b1_mv + b1_mv.min_voltage = 19_000 + b1_mv.max_voltage = 21_000 + # propagate MV voltage limits + b1_mv.propagate_limits() + for bus in (b1_mv, b2_mv, b3_mv): + assert bus.min_voltage == Q_(19_000, "V") + assert bus.max_voltage == Q_(21_000, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage is None + assert bus.max_voltage is None + + # Set min and max voltage of b1_lv + b1_lv.min_voltage = 217 + b1_lv.max_voltage = 253 + b1_lv.propagate_limits() + for bus in (b1_mv, b2_mv, b3_mv): + assert bus.min_voltage == Q_(19_000, "V") + assert bus.max_voltage == Q_(21_000, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage == Q_(217, "V") + assert bus.max_voltage == Q_(253, "V") + + # Reset min MV voltage limits only + b1_mv.min_voltage = None + b1_mv.propagate_limits() + for bus in (b1_mv, b2_mv, b3_mv): + assert bus.min_voltage is None + assert bus.max_voltage == Q_(21_000, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage == Q_(217, "V") + assert bus.max_voltage == Q_(253, "V") + + # Error, different max voltage limits + b1_mv.max_voltage = 21_005 + with pytest.raises(RoseauLoadFlowException) as e: + b1_mv.propagate_limits() + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_VOLTAGES + assert e.value.msg == ( + "Cannot propagate the maximum voltage (21005 V) of bus 'b1_mv' to bus 'b2_mv' with " + "different maximum voltage (21000 V)." + ) + + # The limits are not changed after the error + for bus in (b2_mv, b3_mv): + assert bus.min_voltage is None + assert bus.max_voltage == Q_(21_000, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage == Q_(217, "V") + assert bus.max_voltage == Q_(253, "V") + + # It is okay to propagate with different limits if force=True + b1_mv.propagate_limits(force=True) + for bus in (b1_mv, b2_mv, b3_mv): + assert bus.min_voltage is None + assert bus.max_voltage == Q_(21_005, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage == Q_(217, "V") + assert bus.max_voltage == Q_(253, "V") + + # What if there is a switch? + b4_mv = Bus("b4_mv", phases="abc") + Switch("sw", b2_mv, b4_mv) + b1_mv.propagate_limits() + for bus in (b1_mv, b2_mv, b3_mv, b4_mv): + assert bus.min_voltage is None + assert bus.max_voltage == Q_(21_005, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage == Q_(217, "V") + assert bus.max_voltage == Q_(253, "V") + + # Let's add a MV loop; does it still work? + Line("l3_mv", b1_mv, b3_mv, length=1, parameters=lp_mv, ground=g) + b1_mv.min_voltage = 19_000 + b1_mv.propagate_limits() + for bus in (b1_mv, b2_mv, b3_mv, b4_mv): + assert bus.min_voltage == Q_(19_000, "V") + assert bus.max_voltage == Q_(21_005, "V") + for bus in (b1_lv, b2_lv): + assert bus.min_voltage == Q_(217, "V") + assert bus.max_voltage == Q_(253, "V") diff --git a/roseau/load_flow/models/tests/test_line_parameters.py b/roseau/load_flow/models/tests/test_line_parameters.py index 1dcfb110..92d0f3bf 100644 --- a/roseau/load_flow/models/tests/test_line_parameters.py +++ b/roseau/load_flow/models/tests/test_line_parameters.py @@ -5,6 +5,7 @@ from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode from roseau.load_flow.models import Bus, Ground, Line, LineParameters +from roseau.load_flow.units import Q_ from roseau.load_flow.utils import ConductorType, InsulatorType, LineType @@ -345,3 +346,20 @@ def test_from_name_mv(): lp = LineParameters.from_name_mv("U_AL_150") npt.assert_allclose(lp.z_line.m_as("ohm/km"), z_line_expected) npt.assert_allclose(lp.y_shunt.m_as("S/km"), y_shunt_expected, rtol=1e-4) + + +def test_max_current(): + lp = LineParameters("test", z_line=np.eye(3)) + assert lp.max_current is None + + lp = LineParameters("test", z_line=np.eye(3), max_current=100) + assert lp.max_current == Q_(100, "A") + + lp.max_current = 200 + assert lp.max_current == Q_(200, "A") + + lp.max_current = None + assert lp.max_current is None + + lp.max_current = Q_(3, "kA") + assert lp.max_current == Q_(3_000, "A") diff --git a/roseau/load_flow/models/tests/test_lines.py b/roseau/load_flow/models/tests/test_lines.py index 5d6f4d24..307e9af6 100644 --- a/roseau/load_flow/models/tests/test_lines.py +++ b/roseau/load_flow/models/tests/test_lines.py @@ -95,3 +95,43 @@ def test_line_parameters_shortcut(): # Y assert line.with_shunt assert np.allclose(line.y_shunt.m_as("S"), 0.05 * y_shunt) + + +def test_res_violated(): + bus1 = Bus("bus1", phases="abc") + bus2 = Bus("bus1", phases="abc") + lp = LineParameters("lp", z_line=np.eye(3, dtype=complex)) + line = Line("line", bus1=bus1, bus2=bus2, parameters=lp, length=Q_(50, "m")) + direct_seq = np.exp([0, -2 / 3 * np.pi * 1j, 2 / 3 * np.pi * 1j]) + + bus1._res_potentials = 230 * direct_seq + bus2._res_potentials = 225 * direct_seq + line._res_currents = 10 * direct_seq, -10 * direct_seq + + # No limits + assert line.res_violated is None + + # No constraint violated + lp.max_current = 11 + assert line.res_violated is False + + # Two violations + lp.max_current = 9 + assert line.res_violated is True + + # Side 1 violation + lp.max_current = 11 + line._res_currents = 12 * direct_seq, -10 * direct_seq + assert line.res_violated is True + + # Side 2 violation + lp.max_current = 11 + line._res_currents = 10 * direct_seq, -12 * direct_seq + assert line.res_violated is True + + # A single phase violation + lp.max_current = 11 + line._res_currents = 10 * direct_seq, -10 * direct_seq + line._res_currents[0][0] = 12 * direct_seq[0] + line._res_currents[1][0] = -12 * direct_seq[0] + assert line.res_violated is True diff --git a/roseau/load_flow/models/tests/test_transformer_parameters.py b/roseau/load_flow/models/tests/test_transformer_parameters.py index ce35849f..3b259f6d 100644 --- a/roseau/load_flow/models/tests/test_transformer_parameters.py +++ b/roseau/load_flow/models/tests/test_transformer_parameters.py @@ -460,3 +460,30 @@ def test_print_catalogue(): with console.capture() as capture: TransformerParameters.print_catalogue(ulv=250) assert len(capture.get().split("\n")) == 2 + + +def test_max_power(): + kwds = { + "type": "yzn11", + "psc": 1350.0, + "p0": 145.0, + "i0": 1.8 / 100, + "ulv": 400, + "uhv": 20000, + "sn": 50 * 1e3, + "vsc": 4 / 100, + } + tp = TransformerParameters("test", **kwds) + assert tp.max_power is None + + tp = TransformerParameters("test", **kwds, max_power=60_000) + assert tp.max_power == Q_(60_000, "VA") + + tp.max_power = 55_000 + assert tp.max_power == Q_(55_000, "VA") + + tp.max_power = None + assert tp.max_power is None + + tp.max_power = Q_(65, "kVA") + assert tp.max_power == Q_(65_000, "VA") diff --git a/roseau/load_flow/models/tests/test_transformers.py b/roseau/load_flow/models/tests/test_transformers.py new file mode 100644 index 00000000..c941e18c --- /dev/null +++ b/roseau/load_flow/models/tests/test_transformers.py @@ -0,0 +1,38 @@ +import numpy as np + +from roseau.load_flow.models import Bus, Transformer, TransformerParameters + + +def test_res_violated(): + bus1 = Bus("bus1", phases="abc") + bus2 = Bus("bus1", phases="abcn") + tp = TransformerParameters( + id="tp", psc=1350.0, p0=145.0, i0=1.8 / 100, ulv=400, uhv=20000, sn=50 * 1e3, vsc=4 / 100, type="yzn11" + ) + transformer = Transformer("transformer", bus1=bus1, bus2=bus2, parameters=tp) + direct_seq = np.exp([0, -2 / 3 * np.pi * 1j, 2 / 3 * np.pi * 1j]) + direct_seq_neutral = np.concatenate([direct_seq, [0]]) + + bus1._res_potentials = 20_000 * direct_seq + bus2._res_potentials = 230 * direct_seq_neutral + transformer._res_currents = 0.8 * direct_seq, -65 * direct_seq_neutral + + # No limits + assert transformer.res_violated is None + + # No constraint violated + tp.max_power = 50_000 + assert transformer.res_violated is False + + # Two violations + tp.max_power = 40_000 + assert transformer.res_violated is True + + # Primary side violation + tp.max_power = 47_900 + assert transformer.res_violated is True + + # Secondary side violation + tp.max_power = 50_000 + transformer._res_currents = 0.8 * direct_seq, -80 * direct_seq_neutral + assert transformer.res_violated is True diff --git a/roseau/load_flow/models/transformers/parameters.py b/roseau/load_flow/models/transformers/parameters.py index a00247d6..8717f6d2 100644 --- a/roseau/load_flow/models/transformers/parameters.py +++ b/roseau/load_flow/models/transformers/parameters.py @@ -41,7 +41,7 @@ class TransformerParameters(Identifiable, JsonMixin, CatalogueMixin[pd.DataFrame ) """The pattern to extract the winding of the primary and of the secondary of the transformer.""" - @ureg_wraps(None, (None, None, None, "V", "V", "VA", "W", "", "W", ""), strict=False) + @ureg_wraps(None, (None, None, None, "V", "V", "VA", "W", "", "W", "", "VA"), strict=False) def __init__( self, id: Id, @@ -53,6 +53,7 @@ def __init__( i0: float, psc: float, vsc: float, + max_power: Optional[float] = None, ) -> None: """TransformerParameters constructor. @@ -85,22 +86,11 @@ def __init__( vsc: Voltages on LV side during short-circuit test (%) + + max_power: + The maximum power loading of the transformer (VA). It is not used in the load flow. """ super().__init__(id) - self._sn = sn - self._uhv = uhv - self._ulv = ulv - self._i0 = i0 - self._p0 = p0 - self._psc = psc - self._vsc = vsc - self.type = type - if type in ("single", "center"): - self.winding1 = None - self.winding2 = None - self.phase_displacement = None - else: - self.winding1, self.winding2, self.phase_displacement = self.extract_windings(string=type) # Check if uhv < ulv: @@ -137,6 +127,22 @@ def __init__( f"imaginary part will be null." ) + self._sn = sn + self._uhv = uhv + self._ulv = ulv + self._i0 = i0 + self._p0 = p0 + self._psc = psc + self._vsc = vsc + self.type = type + if type in ("single", "center"): + self.winding1 = None + self.winding2 = None + self.phase_displacement = None + else: + self.winding1, self.winding2, self.phase_displacement = self.extract_windings(string=type) + self.max_power = max_power + def __eq__(self, other: object) -> bool: if not isinstance(other, TransformerParameters): return NotImplemented @@ -195,6 +201,16 @@ def vsc(self) -> Q_[float]: """Voltages on LV side during short-circuit test (%)""" return self._vsc + @property + def max_power(self) -> Optional[Q_[float]]: + """The maximum power loading of the transformer (VA) if it is set.""" + return None if self._max_power is None else Q_(self._max_power, "VA") + + @max_power.setter + @ureg_wraps(None, (None, "VA"), strict=False) + def max_power(self, value: Optional[float]) -> None: + self._max_power = value + @ureg_wraps(("ohm", "S", "", None), (None,), strict=False) def to_zyk(self) -> tuple[Q_[complex], Q_[complex], Q_[float], float]: """Compute the transformer parameters ``z2``, ``ym``, ``k`` and ``orientation`` mandatory @@ -262,10 +278,11 @@ def from_dict(cls, data: JsonDict) -> Self: i0=data["i0"], # Current during off-load test (%) psc=data["psc"], # Losses during short-circuit test (W) vsc=data["vsc"], # Voltages on LV side during short-circuit test (%) + max_power=data.get("max_power"), # Maximum power loading (VA) ) - def to_dict(self, include_geometry: bool = True) -> JsonDict: - return { + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: + res = { "id": self.id, "sn": self._sn, "uhv": self._uhv, @@ -276,6 +293,9 @@ def to_dict(self, include_geometry: bool = True) -> JsonDict: "vsc": self._vsc, "type": self.type, } + if not _lf_only and self.max_power is not None: + res["max_power"] = self.max_power.magnitude + return res def _results_to_dict(self, warning: bool) -> NoReturn: msg = f"The {type(self).__name__} has no results to export." diff --git a/roseau/load_flow/models/transformers/transformers.py b/roseau/load_flow/models/transformers/transformers.py index 300d8432..4dec6691 100644 --- a/roseau/load_flow/models/transformers/transformers.py +++ b/roseau/load_flow/models/transformers/transformers.py @@ -8,6 +8,7 @@ from roseau.load_flow.models.buses import Bus from roseau.load_flow.models.transformers.parameters import TransformerParameters from roseau.load_flow.typing import Id, JsonDict +from roseau.load_flow.units import Q_ logger = logging.getLogger(__name__) @@ -130,8 +131,15 @@ def parameters(self, value: TransformerParameters) -> None: self._parameters = value self._invalidate_network_results() - def to_dict(self, include_geometry: bool = True) -> JsonDict: - return {**super().to_dict(include_geometry=include_geometry), "params_id": self.parameters.id, "tap": self.tap} + @property + def max_power(self) -> Optional[Q_[float]]: + """The maximum power loading of the transformer (in VA).""" + # Do not add a setter. The user must know that if they change the max_power, it changes + # for all transformers that share the parameters. It is better to set it on the parameters. + return self.parameters.max_power + + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: + return {**super().to_dict(_lf_only=_lf_only), "params_id": self.parameters.id, "tap": self.tap} def _compute_phases_three( self, @@ -245,3 +253,16 @@ def _check_bus_phases(id: Id, bus: Bus, **kwargs: str) -> None: ) logger.error(msg) raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_PHASE) + + @property + def res_violated(self) -> Optional[bool]: + """Whether the transformer power exceeds the maximum power (loading > 100%). + + Returns ``None`` if the maximum power is not set. + """ + s_max = self.parameters._max_power + if s_max is None: + return None + powers1, powers2 = self._res_powers_getter(warning=True) + # True if either the primary or secondary is overloaded + return float(max(abs(sum(powers1)), abs(sum(powers2)))) > s_max diff --git a/roseau/load_flow/network.py b/roseau/load_flow/network.py index 4e18b238..8af70487 100644 --- a/roseau/load_flow/network.py +++ b/roseau/load_flow/network.py @@ -39,14 +39,10 @@ from roseau.load_flow.solvers import check_solver_params from roseau.load_flow.typing import Authentication, Id, JsonDict, Solver, StrPath from roseau.load_flow.utils import CatalogueMixin, JsonMixin, console, palette +from roseau.load_flow.utils.types import _DTYPES, VoltagePhaseDtype logger = logging.getLogger(__name__) -# Phases dtype for all data frames -_PHASE_DTYPE = pd.CategoricalDtype(categories=["a", "b", "c", "n"], ordered=True) -# Phases dtype for voltage data frames -_VOLTAGE_PHASES_DTYPE = pd.CategoricalDtype(categories=["an", "bn", "cn", "ab", "bc", "ca"], ordered=True) - _T = TypeVar("_T", bound=Element) @@ -266,10 +262,15 @@ def from_element(cls, initial_bus: Bus) -> Self: @property def buses_frame(self) -> gpd.GeoDataFrame: """The :attr:`buses` of the network as a geo dataframe.""" + data = [] + for bus in self.buses.values(): + min_voltage = bus.min_voltage.magnitude if bus.min_voltage is not None else float("nan") + max_voltage = bus.max_voltage.magnitude if bus.max_voltage is not None else float("nan") + data.append((bus.id, bus.phases, min_voltage, max_voltage, bus.geometry)) return gpd.GeoDataFrame( data=pd.DataFrame.from_records( - data=[(bus_id, bus.phases, bus.geometry) for bus_id, bus in self.buses.items()], - columns=["id", "phases", "geometry"], + data=data, + columns=["id", "phases", "min_voltage", "max_voltage", "geometry"], index="id", ), geometry="geometry", @@ -300,6 +301,94 @@ def branches_frame(self) -> gpd.GeoDataFrame: crs=CRS("EPSG:4326"), ) + @property + def transformers_frame(self) -> gpd.GeoDataFrame: + """The transformers of the network as a geo dataframe. + + This is similar to :attr:`branches_frame` but only contains the transformers. It has a + `max_power` column that contains the maximum power loading (VA) of the transformers. + """ + data = [] + for branch in self.branches.values(): + if not isinstance(branch, Transformer): + continue + max_power = branch.max_power.magnitude if branch.max_power is not None else float("nan") + data.append( + ( + branch.id, + branch.phases1, + branch.phases2, + branch.bus1.id, + branch.bus2.id, + branch.parameters.id, + max_power, + branch.geometry, + ) + ) + return gpd.GeoDataFrame( + data=pd.DataFrame.from_records( + data=data, + columns=["id", "phases1", "phases2", "bus1_id", "bus2_id", "parameters_id", "max_power", "geometry"], + index="id", + ), + geometry="geometry", + crs=CRS("EPSG:4326"), + ) + + @property + def lines_frame(self) -> gpd.GeoDataFrame: + """The lines of the network as a geo dataframe. + + This is similar to :attr:`branches_frame` but only contains the lines. It has a + `max_current` column that contains the maximum current loading (A) of the lines. + """ + data = [] + for branch in self.branches.values(): + if not isinstance(branch, Line): + continue + max_current = branch.max_current.magnitude if branch.max_current is not None else float("nan") + data.append( + ( + branch.id, + branch.phases, + branch.bus1.id, + branch.bus2.id, + branch.parameters.id, + max_current, + branch.geometry, + ) + ) + return gpd.GeoDataFrame( + data=pd.DataFrame.from_records( + data=data, + columns=["id", "phases", "bus1_id", "bus2_id", "parameters_id", "max_current", "geometry"], + index="id", + ), + geometry="geometry", + crs=CRS("EPSG:4326"), + ) + + @property + def switches_frame(self) -> gpd.GeoDataFrame: + """The switches of the network as a geo dataframe. + + This is similar to :attr:`branches_frame` but only contains the switches. + """ + data = [] + for branch in self.branches.values(): + if not isinstance(branch, Switch): + continue + data.append((branch.id, branch.phases, branch.bus1.id, branch.bus2.id, branch.geometry)) + return gpd.GeoDataFrame( + data=pd.DataFrame.from_records( + data=data, + columns=["id", "phases", "bus1_id", "bus2_id", "geometry"], + index="id", + ), + geometry="geometry", + crs=CRS("EPSG:4326"), + ) + @property def loads_frame(self) -> pd.DataFrame: """The :attr:`loads` of the network as a dataframe.""" @@ -411,7 +500,7 @@ def solve_load_flow( # Get the data data = { - "network": self.to_dict(include_geometry=False), + "network": self.to_dict(_lf_only=True), "solver": { "name": solver, "params": solver_params, @@ -557,17 +646,13 @@ def res_buses(self) -> pd.DataFrame: """ self._warn_invalid_results() res_dict = {"bus_id": [], "phase": [], "potential": []} + dtypes = {c: _DTYPES[c] for c in res_dict} for bus_id, bus in self.buses.items(): for potential, phase in zip(bus._res_potentials_getter(warning=False), bus.phases): res_dict["bus_id"].append(bus_id) res_dict["phase"].append(phase) res_dict["potential"].append(potential) - res_df = ( - pd.DataFrame.from_dict(res_dict, orient="columns") - .astype({"phase": _PHASE_DTYPE, "potential": complex}) - .set_index(["bus_id", "phase"]) - ) - return res_df + return pd.DataFrame(res_dict).astype(dtypes).set_index(["bus_id", "phase"]) @property def res_buses_voltages(self) -> pd.DataFrame: @@ -582,20 +667,42 @@ def res_buses_voltages(self) -> pd.DataFrame: - `phase`: The phase of the bus (in ``{'an', 'bn', 'cn', 'ab', 'bc', 'ca'}``). and the following columns: - `voltage`: The complex voltage of the bus (in Volts) for the given phase. + - `min_voltage`: The minimum voltage of the bus (in Volts). + - `max_voltage`: The maximum voltage of the bus (in Volts). """ self._warn_invalid_results() - voltages_dict = {"bus_id": [], "phase": [], "voltage": []} + voltages_dict = { + "bus_id": [], + "phase": [], + "voltage": [], + "min_voltage": [], + "max_voltage": [], + "violated": [], + } + dtypes = {c: _DTYPES[c] for c in voltages_dict} | {"phase": VoltagePhaseDtype} for bus_id, bus in self.buses.items(): + min_voltage = bus._min_voltage + max_voltage = bus._max_voltage + voltage_limits_set = False + + if min_voltage is None: + min_voltage = float("nan") + else: + voltage_limits_set = True + if max_voltage is None: + max_voltage = float("nan") + else: + voltage_limits_set = True for voltage, phase in zip(bus._res_voltages_getter(warning=False), bus.voltage_phases): + voltage_abs = abs(voltage) + violated = (voltage_abs < min_voltage or voltage_abs > max_voltage) if voltage_limits_set else None voltages_dict["bus_id"].append(bus_id) voltages_dict["phase"].append(phase) voltages_dict["voltage"].append(voltage) - voltages_df = ( - pd.DataFrame.from_dict(voltages_dict, orient="columns") - .astype({"phase": _VOLTAGE_PHASES_DTYPE, "voltage": complex}) - .set_index(["bus_id", "phase"]) - ) - return voltages_df + voltages_dict["min_voltage"].append(min_voltage) + voltages_dict["max_voltage"].append(max_voltage) + voltages_dict["violated"].append(violated) + return pd.DataFrame(voltages_dict).astype(dtypes).set_index(["bus_id", "phase"]) @property def res_branches(self) -> pd.DataFrame: @@ -605,6 +712,7 @@ def res_branches(self) -> pd.DataFrame: - `branch_id`: The id of the branch. - `phase`: The phase of the branch (in ``{'a', 'b', 'c', 'n'}``). and the following columns: + - `branch_type`: The type of the branch, can be ``{'line', 'transformer', 'switch'}``. - `current1`: The complex current of the branch (in Amps) for the given phase at the first bus. - `current2`: The complex current of the branch (in Amps) for the given phase at the @@ -626,6 +734,7 @@ def res_branches(self) -> pd.DataFrame: { "branch_id": branch_id, "phase": phase, + "branch_type": branch.branch_type, "current1": i1, "current2": None, "power1": s1, @@ -639,6 +748,7 @@ def res_branches(self) -> pd.DataFrame: { "branch_id": branch_id, "phase": phase, + "branch_type": branch.branch_type, "current1": None, "current2": i2, "power1": None, @@ -649,24 +759,125 @@ def res_branches(self) -> pd.DataFrame: for i2, s2, v2, phase in zip(currents2, powers2, potentials2, branch.phases2) ) - res_df = ( - pd.DataFrame.from_records(res_list) - .astype( + columns = [ + "branch_id", + "phase", + "branch_type", + "current1", + "current2", + "power1", + "power2", + "potential1", + "potential2", + ] + dtypes = {c: _DTYPES[c] for c in columns} + return ( + pd.DataFrame.from_records(res_list, columns=columns) + .astype(dtypes) + # aggregate x1 and x2 for the same phase for I, V, S, ... + .groupby(["branch_id", "phase", "branch_type"], observed=True) + # there are 2 values of I, V, S, ...; only one is not nan -> keep it + .mean() + # if all values are nan -> drop the row (the phase does not exist) + .dropna(how="all") + .reset_index(level="branch_type") + ) + + @property + def res_transformers(self) -> pd.DataFrame: + """The load flow results of the network transformers. + + This is similar to the :attr:`res_branches` property but provides more information that + only apply to transformers. + + The results are returned as a dataframe with the following index: + - `transformer_id`: The id of the transformer. + - `phase`: The phase of the transformer (in ``{'a', 'b', 'c', 'n'}``). + + and the following columns: + - `current1`: The complex current of the transformer (in Amps) for the given phase at the + first bus. + - `current2`: The complex current of the transformer (in Amps) for the given phase at the + second bus. + - `power1`: The complex power of the transformer (in VoltAmps) for the given phase at the + first bus. + - `power2`: The complex power of the transformer (in VoltAmps) for the given phase at the + second bus. + - `potential1`: The complex potential of the first bus (in Volts) for the given phase. + - `potential2`: The complex potential of the second bus (in Volts) for the given phase. + - `max_power`: The maximum power loading (in VoltAmps) of the transformer. + """ + self._warn_invalid_results() + res_list = [] + for branch in self.branches.values(): + if not isinstance(branch, Transformer): + continue + currents1, currents2 = branch._res_currents_getter(warning=False) + powers1, powers2 = branch._res_powers_getter(warning=False) + potentials1, potentials2 = branch._res_potentials_getter(warning=False) + s_max = branch.parameters._max_power + violated = None + if s_max is not None: + violated = max(abs(sum(powers1)), abs(sum(powers2))) > s_max + res_list.extend( + { + "transformer_id": branch.id, + "phase": phase, + "current1": i1, + "current2": None, + "power1": s1, + "power2": None, + "potential1": v1, + "potential2": None, + "max_power": s_max, + "violated": violated, + } + for i1, s1, v1, phase in zip(currents1, powers1, potentials1, branch.phases1) + ) + res_list.extend( { - "phase": _PHASE_DTYPE, - "current1": complex, - "current2": complex, - "power1": complex, - "power2": complex, - "potential1": complex, - "potential2": complex, + "transformer_id": branch.id, + "phase": phase, + "current1": None, + "current2": i2, + "power1": None, + "power2": s2, + "potential1": None, + "potential2": v2, + "max_power": s_max, + "violated": violated, } + for i2, s2, v2, phase in zip(currents2, powers2, potentials2, branch.phases2) ) - .groupby(["branch_id", "phase"], observed=True) # aggregate x1 and x2 for the same phase - .mean() # 2 values; only one is not nan -> keep it - .dropna(how="all") # if all values are nan -> drop the row (the phase does not exist) + + columns = [ + "transformer_id", + "phase", + "current1", + "current2", + "power1", + "power2", + "potential1", + "potential2", + "max_power", + "violated", + ] + dtypes = {c: _DTYPES[c] for c in columns} + res = ( + pd.DataFrame.from_records(res_list, columns=columns) + .astype(dtypes) + # aggregate x1 and x2 for the same phase for I, V, S, ... + .groupby(["transformer_id", "phase", "max_power", "violated"], observed=True) + # there are 2 values of I, V, S, ...; only one is not nan -> keep it + .mean() + # if all values are nan -> drop the row (the phase does not exist) + .dropna(how="all") + .reset_index(level=["max_power", "violated"]) ) - return res_df + # move the max_power and violated columns to the end + res["max_power"] = res.pop("max_power") + res["violated"] = res.pop("violated") + return res @property def res_lines(self) -> pd.DataFrame: @@ -719,7 +930,10 @@ def res_lines(self) -> pd.DataFrame: "potential2": [], "series_losses": [], "series_current": [], + "max_current": [], + "violated": [], } + dtypes = {c: _DTYPES[c] for c in res_dict} for branch in self.branches.values(): if not isinstance(branch, Line): continue @@ -728,9 +942,11 @@ def res_lines(self) -> pd.DataFrame: powers = branch._res_powers_getter(warning=False) series_losses = branch._res_series_power_losses_getter(warning=False) series_currents = branch._res_series_currents_getter(warning=False) + i_max = branch.parameters._max_current for i1, i2, s1, s2, v1, v2, s_series, i_series, phase in zip( *currents, *powers, *potentials, series_losses, series_currents, branch.phases ): + violated = None if i_max is None else max(abs(i1), abs(i2)) > i_max res_dict["line_id"].append(branch.id) res_dict["phase"].append(phase) res_dict["current1"].append(i1) @@ -741,16 +957,60 @@ def res_lines(self) -> pd.DataFrame: res_dict["potential2"].append(v2) res_dict["series_losses"].append(s_series) res_dict["series_current"].append(i_series) - return ( - pd.DataFrame(res_dict) - .astype( - { - "phase": _PHASE_DTYPE, - **{k: complex for k in res_dict if k not in ("phase", "line_id")}, - }, - ) - .set_index(["line_id", "phase"]) - ) + res_dict["max_current"].append(i_max) + res_dict["violated"].append(violated) + res = pd.DataFrame(res_dict).astype(dtypes).set_index(["line_id", "phase"]) + return res + + @property + def res_switches(self) -> pd.DataFrame: + """The load flow results of the network switches. + + This is similar to the :attr:`res_branches` property but only apply to switches. + + The results are returned as a dataframe with the following index: + - `switch_id`: The id of the switch. + - `phase`: The phase of the switch (in ``{'a', 'b', 'c', 'n'}``). + and the following columns: + - `current1`: The complex current of the switch (in Amps) for the given phase at the + first bus. + - `current2`: The complex current of the switch (in Amps) for the given phase at the + second bus. + - `power1`: The complex power of the switch (in VoltAmps) for the given phase at the + first bus. + - `power2`: The complex power of the switch (in VoltAmps) for the given phase at the + second bus. + - `potential1`: The complex potential of the first bus (in Volts) for the given phase. + - `potential2`: The complex potential of the second bus (in Volts) for the given phase. + """ + self._warn_invalid_results() + res_dict = { + "switch_id": [], + "phase": [], + "current1": [], + "current2": [], + "power1": [], + "power2": [], + "potential1": [], + "potential2": [], + } + dtypes = {c: _DTYPES[c] for c in res_dict} + for branch in self.branches.values(): + if not isinstance(branch, Switch): + continue + potentials = branch._res_potentials_getter(warning=False) + currents = branch._res_currents_getter(warning=False) + powers = branch._res_powers_getter(warning=False) + for i1, i2, s1, s2, v1, v2, phase in zip(*currents, *powers, *potentials, branch.phases): + res_dict["switch_id"].append(branch.id) + res_dict["phase"].append(phase) + res_dict["current1"].append(i1) + res_dict["current2"].append(i2) + res_dict["power1"].append(s1) + res_dict["power2"].append(s2) + res_dict["potential1"].append(v1) + res_dict["potential2"].append(v2) + return pd.DataFrame(res_dict).astype(dtypes).set_index(["switch_id", "phase"]) @property def res_loads(self) -> pd.DataFrame: @@ -766,6 +1026,7 @@ def res_loads(self) -> pd.DataFrame: """ self._warn_invalid_results() res_dict = {"load_id": [], "phase": [], "current": [], "power": [], "potential": []} + dtypes = {c: _DTYPES[c] for c in res_dict} for load_id, load in self.loads.items(): currents = load._res_currents_getter(warning=False) powers = load._res_powers_getter(warning=False) @@ -776,12 +1037,7 @@ def res_loads(self) -> pd.DataFrame: res_dict["current"].append(i) res_dict["power"].append(s) res_dict["potential"].append(v) - res_df = ( - pd.DataFrame.from_dict(res_dict, orient="columns") - .astype({"phase": _PHASE_DTYPE, "current": complex, "power": complex, "potential": complex}) - .set_index(["load_id", "phase"]) - ) - return res_df + return pd.DataFrame(res_dict).astype(dtypes).set_index(["load_id", "phase"]) @property def res_loads_voltages(self) -> pd.DataFrame: @@ -796,17 +1052,13 @@ def res_loads_voltages(self) -> pd.DataFrame: """ self._warn_invalid_results() voltages_dict = {"load_id": [], "phase": [], "voltage": []} + dtypes = {c: _DTYPES[c] for c in voltages_dict} | {"phase": VoltagePhaseDtype} for load_id, load in self.loads.items(): for voltage, phase in zip(load._res_voltages_getter(warning=False), load.voltage_phases): voltages_dict["load_id"].append(load_id) voltages_dict["phase"].append(phase) voltages_dict["voltage"].append(voltage) - voltages_df = ( - pd.DataFrame.from_dict(voltages_dict, orient="columns") - .astype({"phase": _VOLTAGE_PHASES_DTYPE, "voltage": complex}) - .set_index(["load_id", "phase"]) - ) - return voltages_df + return pd.DataFrame(voltages_dict).astype(dtypes).set_index(["load_id", "phase"]) @property def res_loads_flexible_powers(self) -> pd.DataFrame: @@ -824,6 +1076,7 @@ def res_loads_flexible_powers(self) -> pd.DataFrame: """ self._warn_invalid_results() loads_dict = {"load_id": [], "phase": [], "power": []} + dtypes = {c: _DTYPES[c] for c in loads_dict} | {"phase": VoltagePhaseDtype} for load_id, load in self.loads.items(): if not (isinstance(load, PowerLoad) and load.is_flexible): continue @@ -831,12 +1084,7 @@ def res_loads_flexible_powers(self) -> pd.DataFrame: loads_dict["load_id"].append(load_id) loads_dict["phase"].append(phase) loads_dict["power"].append(power) - powers_df = ( - pd.DataFrame.from_dict(loads_dict, orient="columns") - .astype({"phase": _VOLTAGE_PHASES_DTYPE, "power": complex}) - .set_index(["load_id", "phase"]) - ) - return powers_df + return pd.DataFrame(loads_dict).astype(dtypes).set_index(["load_id", "phase"]) @property def res_sources(self) -> pd.DataFrame: @@ -852,6 +1100,7 @@ def res_sources(self) -> pd.DataFrame: """ self._warn_invalid_results() res_dict = {"source_id": [], "phase": [], "current": [], "power": [], "potential": []} + dtypes = {c: _DTYPES[c] for c in res_dict} for source_id, source in self.sources.items(): currents = source._res_currents_getter(warning=False) powers = source._res_powers_getter(warning=False) @@ -862,12 +1111,7 @@ def res_sources(self) -> pd.DataFrame: res_dict["current"].append(i) res_dict["power"].append(s) res_dict["potential"].append(v) - res_df = ( - pd.DataFrame.from_dict(res_dict, orient="columns") - .astype({"phase": _PHASE_DTYPE, "current": complex, "power": complex, "potential": complex}) - .set_index(["source_id", "phase"]) - ) - return res_df + return pd.DataFrame(res_dict).astype(dtypes).set_index(["source_id", "phase"]) @property def res_grounds(self) -> pd.DataFrame: @@ -880,14 +1124,12 @@ def res_grounds(self) -> pd.DataFrame: """ self._warn_invalid_results() res_dict = {"ground_id": [], "potential": []} + dtypes = {c: _DTYPES[c] for c in res_dict} for ground in self.grounds.values(): potential = ground._res_potential_getter(warning=False) res_dict["ground_id"].append(ground.id) res_dict["potential"].append(potential) - res_df = ( - pd.DataFrame.from_dict(res_dict, orient="columns").astype({"potential": complex}).set_index(["ground_id"]) - ) - return res_df + return pd.DataFrame(res_dict).astype(dtypes).set_index(["ground_id"]) @property def res_potential_refs(self) -> pd.DataFrame: @@ -901,18 +1143,14 @@ def res_potential_refs(self) -> pd.DataFrame: """ self._warn_invalid_results() res_dict = {"potential_ref_id": [], "current": []} + dtypes = {c: _DTYPES[c] for c in res_dict} for p_ref in self.potential_refs.values(): current = p_ref._res_current_getter(warning=False) res_dict["potential_ref_id"].append(p_ref.id) res_dict["current"].append(current) - res_df = ( - pd.DataFrame.from_dict(res_dict, orient="columns") - .astype({"current": complex}) - .set_index(["potential_ref_id"]) - ) - return res_df + return pd.DataFrame(res_dict).astype(dtypes).set_index(["potential_ref_id"]) - def clear_short_circuits(self): + def clear_short_circuits(self) -> None: """Remove the short-circuits of all the buses.""" for bus in self.buses.values(): bus.clear_short_circuits() @@ -1095,14 +1333,14 @@ def from_dict(cls, data: JsonDict) -> Self: potential_refs=p_refs, ) - def to_dict(self, include_geometry: bool = True) -> JsonDict: + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: """Convert the electrical network to a dictionary. Args: - include_geometry: - If False, the geometry will not be added to the network dictionary. + _lf_only: + Internal argument, please do not use. """ - return network_to_dict(self, include_geometry=include_geometry) + return network_to_dict(self, _lf_only=_lf_only) # # Results saving/loading diff --git a/roseau/load_flow/tests/test_converters.py b/roseau/load_flow/tests/test_converters.py index e67d6157..929dacc6 100644 --- a/roseau/load_flow/tests/test_converters.py +++ b/roseau/load_flow/tests/test_converters.py @@ -3,7 +3,7 @@ from pandas.testing import assert_series_equal from roseau.load_flow.converters import phasor_to_sym, series_phasor_to_sym, sym_to_phasor -from roseau.load_flow.network import _PHASE_DTYPE +from roseau.load_flow.utils import PhaseDtype def test_phasor_to_sym(): @@ -88,7 +88,7 @@ def test_series_phasor_to_sym(): [("bus1", "a"), ("bus1", "b"), ("bus1", "c"), ("bus2", "a"), ("bus2", "b"), ("bus2", "c")], names=["bus_id", "phase"], ) - index = index.set_levels(index.levels[-1].astype(_PHASE_DTYPE), level=-1) + index = index.set_levels(index.levels[-1].astype(PhaseDtype), level=-1) voltage = pd.Series([va, vb, vc, va / 2, vb / 2, vc / 2], index=index, name="voltage") seq_dtype = pd.CategoricalDtype(categories=["zero", "pos", "neg"], ordered=True) diff --git a/roseau/load_flow/tests/test_electrical_network.py b/roseau/load_flow/tests/test_electrical_network.py index 42b0fefc..7271fd6c 100644 --- a/roseau/load_flow/tests/test_electrical_network.py +++ b/roseau/load_flow/tests/test_electrical_network.py @@ -26,9 +26,9 @@ TransformerParameters, VoltageSource, ) -from roseau.load_flow.network import _PHASE_DTYPE, _VOLTAGE_PHASES_DTYPE, ElectricalNetwork +from roseau.load_flow.network import ElectricalNetwork from roseau.load_flow.units import Q_ -from roseau.load_flow.utils import console +from roseau.load_flow.utils import BranchTypeDtype, PhaseDtype, VoltagePhaseDtype, console @pytest.fixture() @@ -647,12 +647,12 @@ def test_solve_load_flow_error(small_network): assert e.value.code == RoseauLoadFlowExceptionCode.BAD_REQUEST -def test_frame(small_network): +def test_frame(small_network: ElectricalNetwork): # Buses buses_gdf = small_network.buses_frame assert isinstance(buses_gdf, gpd.GeoDataFrame) - assert buses_gdf.shape == (2, 2) - assert set(buses_gdf.columns) == {"phases", "geometry"} + assert buses_gdf.shape == (2, 4) + assert set(buses_gdf.columns) == {"phases", "min_voltage", "max_voltage", "geometry"} assert buses_gdf.index.name == "id" # Branches @@ -662,6 +662,33 @@ def test_frame(small_network): assert set(branches_gdf.columns) == {"branch_type", "phases1", "phases2", "bus1_id", "bus2_id", "geometry"} assert branches_gdf.index.name == "id" + # Transformers + transformers_gdf = small_network.transformers_frame + assert isinstance(transformers_gdf, gpd.GeoDataFrame) + assert transformers_gdf.shape == (0, 7) + assert set(transformers_gdf.columns) == { + "phases1", + "phases2", + "bus1_id", + "bus2_id", + "parameters_id", + "max_power", + "geometry", + } + assert transformers_gdf.index.name == "id" + + # Lines + lines_gdf = small_network.lines_frame + assert isinstance(lines_gdf, gpd.GeoDataFrame) + assert lines_gdf.shape == (1, 6) + assert set(lines_gdf.columns) == {"phases", "bus1_id", "bus2_id", "parameters_id", "max_current", "geometry"} + + # Switches + switches_gdf = small_network.switches_frame + assert isinstance(switches_gdf, gpd.GeoDataFrame) + assert switches_gdf.shape == (0, 4) + assert set(switches_gdf.columns) == {"phases", "bus1_id", "bus2_id", "geometry"} + # Loads loads_df = small_network.loads_frame assert isinstance(loads_df, pd.DataFrame) @@ -677,30 +704,169 @@ def test_frame(small_network): assert sources_df.index.name == "id" -def test_buses_voltages(small_network, good_json_results): +def test_frame_empty_network(monkeypatch): + # Test that we can create dataframes even if a certain element is not present in the network + monkeypatch.setattr(ElectricalNetwork, "_check_validity", lambda self, constructed: None) + monkeypatch.setattr(ElectricalNetwork, "_warn_invalid_results", lambda self: None) + empty_network = ElectricalNetwork( + buses={}, + branches={}, + loads={}, + sources={}, + grounds={}, + potential_refs={}, + ) + # Buses + buses = empty_network.buses_frame + assert buses.shape == (0, 4) + assert buses.empty + + # Branches + branches = empty_network.branches_frame + assert branches.shape == (0, 6) + assert branches.empty + + # Transformers + transformers = empty_network.transformers_frame + assert transformers.shape == (0, 7) + assert transformers.empty + + # Lines + lines = empty_network.lines_frame + assert lines.shape == (0, 6) + assert lines.empty + + # Switches + switches = empty_network.switches_frame + assert switches.shape == (0, 4) + assert switches.empty + + # Loads + loads = empty_network.loads_frame + assert loads.shape == (0, 2) + assert loads.empty + + # Sources + sources = empty_network.sources_frame + assert sources.shape == (0, 2) + assert sources.empty + + # Res buses + res_buses = empty_network.res_buses + assert res_buses.shape == (0, 1) + assert res_buses.empty + res_buses_voltages = empty_network.res_buses_voltages + assert res_buses_voltages.shape == (0, 4) + assert res_buses_voltages.empty + + # Res branches + res_branches = empty_network.res_branches + assert res_branches.shape == (0, 7) + assert res_branches.empty + + # Res transformers + res_transformers = empty_network.res_transformers + assert res_transformers.shape == (0, 8) + assert res_transformers.empty + + # Res lines + res_lines = empty_network.res_lines + assert res_lines.shape == (0, 10) + assert res_lines.empty + + # Res switches + res_switches = empty_network.res_switches + assert res_switches.shape == (0, 6) + assert res_switches.empty + + # Res loads + res_loads = empty_network.res_loads + assert res_loads.shape == (0, 3) + assert res_loads.empty + + # Res sources + res_sources = empty_network.res_sources + assert res_sources.shape == (0, 3) + assert res_sources.empty + + +def test_buses_voltages(small_network: ElectricalNetwork, good_json_results): assert isinstance(small_network, ElectricalNetwork) small_network.results_from_dict(good_json_results) + small_network.buses["bus0"].max_voltage = 21_000 + small_network.buses["bus1"].min_voltage = 20_000 voltage_records = [ - {"bus_id": "bus0", "phase": "an", "voltage": 20000.0 + 0.0j}, - {"bus_id": "bus0", "phase": "bn", "voltage": -10000.0 + -17320.508076j}, - {"bus_id": "bus0", "phase": "cn", "voltage": -10000.0 + 17320.508076j}, - {"bus_id": "bus1", "phase": "an", "voltage": 19999.949999875 + 0.0j}, - {"bus_id": "bus1", "phase": "bn", "voltage": -9999.9749999375 + -17320.464774621556j}, - {"bus_id": "bus1", "phase": "cn", "voltage": -9999.9749999375 + 17320.464774621556j}, + { + "bus_id": "bus0", + "phase": "an", + "voltage": 20000.0 + 0.0j, + "min_voltage": np.nan, + "max_voltage": 21000, + "violated": False, + }, + { + "bus_id": "bus0", + "phase": "bn", + "voltage": -10000.0 + -17320.508076j, + "min_voltage": np.nan, + "max_voltage": 21000, + "violated": False, + }, + { + "bus_id": "bus0", + "phase": "cn", + "voltage": -10000.0 + 17320.508076j, + "min_voltage": np.nan, + "max_voltage": 21000, + "violated": False, + }, + { + "bus_id": "bus1", + "phase": "an", + "voltage": 19999.949999875 + 0.0j, + "min_voltage": 20000, + "max_voltage": np.nan, + "violated": True, + }, + { + "bus_id": "bus1", + "phase": "bn", + "voltage": -9999.9749999375 + -17320.464774621556j, + "min_voltage": 20000, + "max_voltage": np.nan, + "violated": True, + }, + { + "bus_id": "bus1", + "phase": "cn", + "voltage": -9999.9749999375 + 17320.464774621556j, + "min_voltage": 20000, + "max_voltage": np.nan, + "violated": True, + }, ] - def set_index_dtype(idx: pd.MultiIndex) -> pd.MultiIndex: - return idx.set_levels(idx.levels[1].astype(_VOLTAGE_PHASES_DTYPE), level=1) - buses_voltages = small_network.res_buses_voltages - expected_buses_voltages = pd.DataFrame.from_records(voltage_records, index=["bus_id", "phase"]) - expected_buses_voltages.index = set_index_dtype(expected_buses_voltages.index) + expected_buses_voltages = ( + pd.DataFrame.from_records(voltage_records) + .astype( + { + "bus_id": str, + "phase": VoltagePhaseDtype, + "voltage": complex, + "min_voltage": float, + "max_voltage": float, + "violated": pd.BooleanDtype(), + } + ) + .set_index(["bus_id", "phase"]) + ) assert isinstance(buses_voltages, pd.DataFrame) - assert buses_voltages.shape == (6, 1) + assert buses_voltages.shape == (6, 4) assert buses_voltages.index.names == ["bus_id", "phase"] - assert list(buses_voltages.columns) == ["voltage"] + assert list(buses_voltages.columns) == ["voltage", "min_voltage", "max_voltage", "violated"] assert_frame_equal(buses_voltages, expected_buses_voltages) @@ -720,6 +886,9 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): new_net = ElectricalNetwork.from_dict(net_dict) assert_frame_equal(single_phase_network.buses_frame, new_net.buses_frame) assert_frame_equal(single_phase_network.branches_frame, new_net.branches_frame) + assert_frame_equal(single_phase_network.transformers_frame, new_net.transformers_frame) + assert_frame_equal(single_phase_network.lines_frame, new_net.lines_frame) + assert_frame_equal(single_phase_network.switches_frame, new_net.switches_frame) assert_frame_equal(single_phase_network.loads_frame, new_net.loads_frame) assert_frame_equal(single_phase_network.sources_frame, new_net.sources_frame) @@ -804,7 +973,7 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): {"bus_id": "bus1", "phase": "n", "potential": 0j}, ] ) - .astype({"phase": _PHASE_DTYPE, "potential": complex}) + .astype({"phase": PhaseDtype, "potential": complex}) .set_index(["bus_id", "phase"]), ) # Buses voltages results @@ -812,11 +981,33 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): single_phase_network.res_buses_voltages, pd.DataFrame.from_records( [ - {"bus_id": "bus0", "phase": "bn", "voltage": (19999.94999975 + 0j) - (-0.050000250001249996 + 0j)}, - {"bus_id": "bus1", "phase": "bn", "voltage": (19999.899999499998 + 0j) - (0j)}, + { + "bus_id": "bus0", + "phase": "bn", + "voltage": (19999.94999975 + 0j) - (-0.050000250001249996 + 0j), + "min_voltage": np.nan, + "max_voltage": np.nan, + "violated": None, + }, + { + "bus_id": "bus1", + "phase": "bn", + "voltage": (19999.899999499998 + 0j) - (0j), + "min_voltage": np.nan, + "max_voltage": np.nan, + "violated": None, + }, ] ) - .astype({"phase": _VOLTAGE_PHASES_DTYPE, "voltage": complex}) + .astype( + { + "phase": VoltagePhaseDtype, + "voltage": complex, + "min_voltage": float, + "max_voltage": float, + "violated": pd.BooleanDtype(), + } + ) .set_index(["bus_id", "phase"]), ) # Branches results @@ -827,6 +1018,7 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): { "branch_id": "line", "phase": "b", + "branch_type": "line", "current1": 0.005000025000117603 + 0j, "current2": -0.005000025000117603 - 0j, "power1": (19999.94999975 + 0j) * (0.005000025000117603 + 0j).conjugate(), @@ -837,6 +1029,7 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): { "branch_id": "line", "phase": "n", + "branch_type": "line", "current1": -0.005000025000125 + 0j, "current2": 0.005000025000125 - 0j, "power1": (-0.050000250001249996 + 0j) * (-0.005000025000125 + 0j).conjugate(), @@ -848,7 +1041,8 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): ) .astype( { - "phase": _PHASE_DTYPE, + "phase": PhaseDtype, + "branch_type": BranchTypeDtype, "current1": complex, "current2": complex, "power1": complex, @@ -859,8 +1053,43 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): ) .set_index(["branch_id", "phase"]), ) + + # Transformers results + pd.testing.assert_frame_equal( + single_phase_network.res_transformers, + pd.DataFrame.from_records( + [], + columns=[ + "transformer_id", + "phase", + "current1", + "current2", + "power1", + "power2", + "potential1", + "potential2", + "max_power", + "violated", + ], + ) + .astype( + { + "phase": PhaseDtype, + "current1": complex, + "current2": complex, + "power1": complex, + "power2": complex, + "potential1": complex, + "potential2": complex, + "max_power": float, + "violated": pd.BooleanDtype(), + } + ) + .set_index(["transformer_id", "phase"]), + ) # Lines results - expected_res_lines = ( + pd.testing.assert_frame_equal( + single_phase_network.res_lines, pd.DataFrame.from_records( [ { @@ -877,6 +1106,8 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): + (19999.899999499998 + 0j) * (-0.005000025000117603 - 0j).conjugate() ), "series_current": 0.005000025000117603 + 0j, + "max_current": np.nan, + "violated": None, }, { "line_id": "line", @@ -892,12 +1123,14 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): + (0j) * (0.005000025000125 - 0j).conjugate() ), "series_current": -0.005000025000125 + 0j, + "max_current": np.nan, + "violated": None, }, ] ) .astype( { - "phase": _PHASE_DTYPE, + "phase": PhaseDtype, "current1": complex, "current2": complex, "power1": complex, @@ -906,11 +1139,41 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): "potential2": complex, "series_losses": complex, "series_current": complex, + "max_current": float, + "violated": pd.BooleanDtype(), } ) - .set_index(["line_id", "phase"]) + .set_index(["line_id", "phase"]), + ) + # Switches results + pd.testing.assert_frame_equal( + single_phase_network.res_switches, + pd.DataFrame.from_records( + [], + columns=[ + "switch_id", + "phase", + "current1", + "current2", + "power1", + "power2", + "potential1", + "potential2", + ], + ) + .astype( + { + "phase": PhaseDtype, + "current1": complex, + "current2": complex, + "power1": complex, + "power2": complex, + "potential1": complex, + "potential2": complex, + } + ) + .set_index(["switch_id", "phase"]), ) - pd.testing.assert_frame_equal(single_phase_network.res_lines, expected_res_lines) # Loads results pd.testing.assert_frame_equal( single_phase_network.res_loads, @@ -932,12 +1195,12 @@ def test_single_phase_network(single_phase_network: ElectricalNetwork): }, ] ) - .astype({"phase": _PHASE_DTYPE, "current": complex, "power": complex, "potential": complex}) + .astype({"phase": PhaseDtype, "current": complex, "power": complex, "potential": complex}) .set_index(["load_id", "phase"]), ) -def test_network_elements(small_network): +def test_network_elements(small_network: ElectricalNetwork): # Add a line to the network ("bus2" constructor belongs to the network) bus1 = small_network.buses["bus1"] bus2 = Bus("bus2", phases="abcn") @@ -1112,159 +1375,447 @@ def test_network_results_warning(small_network: ElectricalNetwork, good_json_res def test_load_flow_results_frames(small_network: ElectricalNetwork, good_json_results: dict): small_network.results_from_dict(good_json_results) + small_network.buses["bus0"].min_voltage = 21_000 - def set_index_dtype(df, dtype): - df.index = df.index.set_levels(df.index.levels[1].astype(dtype), level=1) - - expected_res_buses = pd.DataFrame.from_records( - [ - {"bus_id": "bus0", "phase": "a", "potential": 20000 + 2.89120e-18j}, - {"bus_id": "bus0", "phase": "b", "potential": -10000.00000 - 17320.50807j}, - {"bus_id": "bus0", "phase": "c", "potential": -10000.00000 + 17320.50807j}, - {"bus_id": "bus0", "phase": "n", "potential": -1.34764e-12 + 2.89120e-18j}, - {"bus_id": "bus1", "phase": "a", "potential": 19999.94999 + 2.89119e-18j}, - {"bus_id": "bus1", "phase": "b", "potential": -9999.97499 - 17320.46477j}, - {"bus_id": "bus1", "phase": "c", "potential": -9999.97499 + 17320.46477j}, - {"bus_id": "bus1", "phase": "n", "potential": 0j}, - ], - index=["bus_id", "phase"], + # Buses results + expected_res_buses = ( + pd.DataFrame.from_records( + [ + {"bus_id": "bus0", "phase": "a", "potential": 20000 + 2.89120e-18j}, + {"bus_id": "bus0", "phase": "b", "potential": -10000.00000 - 17320.50807j}, + {"bus_id": "bus0", "phase": "c", "potential": -10000.00000 + 17320.50807j}, + {"bus_id": "bus0", "phase": "n", "potential": -1.34764e-12 + 2.89120e-18j}, + {"bus_id": "bus1", "phase": "a", "potential": 19999.94999 + 2.89119e-18j}, + {"bus_id": "bus1", "phase": "b", "potential": -9999.97499 - 17320.46477j}, + {"bus_id": "bus1", "phase": "c", "potential": -9999.97499 + 17320.46477j}, + {"bus_id": "bus1", "phase": "n", "potential": 0j}, + ] + ) + .astype({"bus_id": object, "phase": PhaseDtype, "potential": complex}) + .set_index(["bus_id", "phase"]) ) - set_index_dtype(expected_res_buses, _PHASE_DTYPE) assert_frame_equal(small_network.res_buses, expected_res_buses, rtol=1e-4) - expected_res_branches = pd.DataFrame.from_records( - [ - { - "branch_id": "line", - "phase": "a", - "current1": 0.00500 + 7.22799e-25j, - "current2": -0.00500 - 7.22799e-25j, - "power1": (20000 + 2.89120e-18j) * (0.00500 + 7.22799e-25j).conjugate(), - "power2": (19999.94999 + 2.89119e-18j) * (-0.00500 - 7.22799e-25j).conjugate(), - "potential1": 20000 + 2.89120e-18j, - "potential2": 19999.94999 + 2.89119e-18j, - }, - { - "branch_id": "line", - "phase": "b", - "current1": -0.00250 - 0.00433j, - "current2": 0.00250 + 0.00433j, - "power1": (-10000.00000 - 17320.50807j) * (-0.00250 - 0.00433j).conjugate(), - "power2": (-9999.97499 - 17320.46477j) * (0.00250 + 0.00433j).conjugate(), - "potential1": -10000.00000 - 17320.50807j, - "potential2": -9999.97499 - 17320.46477j, - }, + # Buses voltages results + expected_res_buses_voltages = ( + pd.DataFrame.from_records( + [ + { + "bus_id": "bus0", + "phase": "an", + "voltage": (20000 + 2.89120e-18j) - (-1.34764e-12 + 2.89120e-18j), + "min_voltage": 21_000, + "max_voltage": np.nan, + "violated": True, + }, + { + "bus_id": "bus0", + "phase": "bn", + "voltage": (-10000.00000 - 17320.50807j) - (-1.34764e-12 + 2.89120e-18j), + "min_voltage": 21_000, + "max_voltage": np.nan, + "violated": True, + }, + { + "bus_id": "bus0", + "phase": "cn", + "voltage": (-10000.00000 + 17320.50807j) - (-1.34764e-12 + 2.89120e-18j), + "min_voltage": 21_000, + "max_voltage": np.nan, + "violated": True, + }, + { + "bus_id": "bus1", + "phase": "an", + "voltage": (19999.94999 + 2.89119e-18j) - (0j), + "min_voltage": np.nan, + "max_voltage": np.nan, + "violated": None, + }, + { + "bus_id": "bus1", + "phase": "bn", + "voltage": (-9999.97499 - 17320.46477j) - (0j), + "min_voltage": np.nan, + "max_voltage": np.nan, + "violated": None, + }, + { + "bus_id": "bus1", + "phase": "cn", + "voltage": (-9999.97499 + 17320.46477j) - (0j), + "min_voltage": np.nan, + "max_voltage": np.nan, + "violated": None, + }, + ] + ) + .astype( { - "branch_id": "line", - "phase": "c", - "current1": -0.00250 + 0.00433j, - "current2": 0.00250 - 0.00433j, - "power1": (-10000.00000 + 17320.50807j) * (-0.00250 + 0.00433j).conjugate(), - "power2": (-9999.97499 + 17320.46477j) * (0.00250 - 0.00433j).conjugate(), - "potential1": -10000.00000 + 17320.50807j, - "potential2": -9999.97499 + 17320.46477j, - }, + "bus_id": object, + "phase": VoltagePhaseDtype, + "voltage": complex, + "min_voltage": float, + "max_voltage": float, + "violated": pd.BooleanDtype(), + } + ) + .set_index(["bus_id", "phase"]) + ) + assert_frame_equal(small_network.res_buses_voltages, expected_res_buses_voltages, rtol=1e-4) + + # Branches results + expected_res_branches = ( + pd.DataFrame.from_records( + [ + { + "branch_id": "line", + "phase": "a", + "branch_type": "line", + "current1": 0.00500 + 7.22799e-25j, + "current2": -0.00500 - 7.22799e-25j, + "power1": (20000 + 2.89120e-18j) * (0.00500 + 7.22799e-25j).conjugate(), + "power2": (19999.94999 + 2.89119e-18j) * (-0.00500 - 7.22799e-25j).conjugate(), + "potential1": 20000 + 2.89120e-18j, + "potential2": 19999.94999 + 2.89119e-18j, + }, + { + "branch_id": "line", + "phase": "b", + "branch_type": "line", + "current1": -0.00250 - 0.00433j, + "current2": 0.00250 + 0.00433j, + "power1": (-10000.00000 - 17320.50807j) * (-0.00250 - 0.00433j).conjugate(), + "power2": (-9999.97499 - 17320.46477j) * (0.00250 + 0.00433j).conjugate(), + "potential1": -10000.00000 - 17320.50807j, + "potential2": -9999.97499 - 17320.46477j, + }, + { + "branch_id": "line", + "phase": "c", + "branch_type": "line", + "current1": -0.00250 + 0.00433j, + "current2": 0.00250 - 0.00433j, + "power1": (-10000.00000 + 17320.50807j) * (-0.00250 + 0.00433j).conjugate(), + "power2": (-9999.97499 + 17320.46477j) * (0.00250 - 0.00433j).conjugate(), + "potential1": -10000.00000 + 17320.50807j, + "potential2": -9999.97499 + 17320.46477j, + }, + { + "branch_id": "line", + "phase": "n", + "branch_type": "line", + "current1": -1.34764e-13 + 2.89120e-19j, + "current2": 1.34764e-13 - 2.89120e-19j, + "power1": (-1.34764e-12 + 2.89120e-18j) * (-1.34764e-13 + 2.89120e-19j).conjugate(), + "power2": (0j) * (1.34764e-13 - 2.89120e-19j).conjugate(), + "potential1": -1.34764e-12 + 2.89120e-18j, + "potential2": 0j, + }, + ], + ) + .astype( { - "branch_id": "line", - "phase": "n", - "current1": -1.34764e-13 + 2.89120e-19j, - "current2": 1.34764e-13 - 2.89120e-19j, - "power1": (-1.34764e-12 + 2.89120e-18j) * (-1.34764e-13 + 2.89120e-19j).conjugate(), - "power2": (0j) * (1.34764e-13 - 2.89120e-19j).conjugate(), - "potential1": -1.34764e-12 + 2.89120e-18j, - "potential2": 0j, - }, - ], - index=["branch_id", "phase"], + "branch_id": object, + "phase": PhaseDtype, + "branch_type": BranchTypeDtype, + "current1": complex, + "current2": complex, + "power1": complex, + "power2": complex, + "potential1": complex, + "potential2": complex, + } + ) + .set_index(["branch_id", "phase"]) ) - set_index_dtype(expected_res_branches, _PHASE_DTYPE) assert_frame_equal(small_network.res_branches, expected_res_branches, rtol=1e-4) - expected_res_loads = pd.DataFrame.from_records( - [ - { - "load_id": "load", - "phase": "a", - "current": 0.00500 + 7.22802e-25j, - "power": (19999.94999 + 2.89119e-18j) * (0.00500 + 7.22802e-25j).conjugate(), - "potential": 19999.94999 + 2.89119e-18j, - }, + # Transformers results + expected_res_transformers = ( + pd.DataFrame.from_records( + [], + columns=[ + "transformer_id", + "phase", + "current1", + "current2", + "power1", + "power2", + "potential1", + "potential2", + "max_power", + "violated", + ], + ) + .astype( { - "load_id": "load", - "phase": "b", - "current": -0.00250 - 0.00433j, - "power": (-9999.97499 - 17320.46477j) * (-0.00250 - 0.00433j).conjugate(), - "potential": -9999.97499 - 17320.46477j, - }, + "transformer_id": object, + "phase": PhaseDtype, + "current1": complex, + "current2": complex, + "power1": complex, + "power2": complex, + "potential1": complex, + "potential2": complex, + "max_power": float, + "violated": pd.BooleanDtype(), + } + ) + .set_index(["transformer_id", "phase"]) + ) + assert_frame_equal(small_network.res_transformers, expected_res_transformers) + + # Lines results + expected_res_lines_records = [ + { + "line_id": "line", + "phase": "a", + "current1": 0.00500 + 7.22799e-25j, + "current2": -0.00500 - 7.22799e-25j, + "power1": (20000 + 2.89120e-18j) * (0.00500 + 7.22799e-25j).conjugate(), + "power2": (19999.94999 + 2.89119e-18j) * (-0.00500 - 7.22799e-25j).conjugate(), + "potential1": 20000 + 2.89120e-18j, + "potential2": 19999.94999 + 2.89119e-18j, + "series_losses": ( + (20000 + 2.89120e-18j) * (0.00500 + 7.22799e-25j).conjugate() + + (19999.94999 + 2.89119e-18j) * (-0.00500 - 7.22799e-25j).conjugate() + ), + "series_current": 0.00500 + 7.22799e-25j, + "max_current": np.nan, + "violated": None, + }, + { + "line_id": "line", + "phase": "b", + "current1": -0.00250 - 0.00433j, + "current2": 0.00250 + 0.00433j, + "power1": (-10000.00000 - 17320.50807j) * (-0.00250 - 0.00433j).conjugate(), + "power2": (-9999.97499 - 17320.46477j) * (0.00250 + 0.00433j).conjugate(), + "potential1": -10000.00000 - 17320.50807j, + "potential2": -9999.97499 - 17320.46477j, + "series_losses": ( + (-10000.00000 - 17320.50807j) * (-0.00250 - 0.00433j).conjugate() + + (-9999.97499 - 17320.46477j) * (0.00250 + 0.00433j).conjugate() + ), + "series_current": -0.00250 - 0.00433j, + "max_current": np.nan, + "violated": None, + }, + { + "line_id": "line", + "phase": "c", + "current1": -0.00250 + 0.00433j, + "current2": 0.00250 - 0.00433j, + "power1": (-10000.00000 + 17320.50807j) * (-0.00250 + 0.00433j).conjugate(), + "power2": (-9999.97499 + 17320.46477j) * (0.00250 - 0.00433j).conjugate(), + "potential1": -10000.00000 + 17320.50807j, + "potential2": -9999.97499 + 17320.46477j, + "series_losses": ( + (-10000.00000 + 17320.50807j) * (-0.00250 + 0.00433j).conjugate() + + (-9999.97499 + 17320.46477j) * (0.00250 - 0.00433j).conjugate() + ), + "series_current": -0.00250 + 0.00433j, + "max_current": np.nan, + "violated": None, + }, + { + "line_id": "line", + "phase": "n", + "current1": -1.34764e-13 + 2.89120e-19j, + "current2": 1.34764e-13 - 2.89120e-19j, + "power1": (-1.34764e-12 + 2.89120e-18j) * (-1.34764e-13 + 2.89120e-19j).conjugate(), + "power2": (0j) * (1.34764e-13 - 2.89120e-19j).conjugate(), + "potential1": -1.34764e-12 + 2.89120e-18j, + "potential2": 0j, + "series_losses": ( + (-1.34764e-12 + 2.89120e-18j) * (-1.34764e-13 + 2.89120e-19j).conjugate() + + (0j) * (1.34764e-13 - 2.89120e-19j).conjugate() + ), + "series_current": -1.34764e-13 + 2.89120e-19j, + "max_current": np.nan, + "violated": None, + }, + ] + expected_res_lines_dtypes = { + "line_id": object, + "phase": PhaseDtype, + "current1": complex, + "current2": complex, + "power1": complex, + "power2": complex, + "potential1": complex, + "potential2": complex, + "series_losses": complex, + "series_current": complex, + "max_current": float, + "violated": pd.BooleanDtype(), + } + expected_res_lines = ( + pd.DataFrame.from_records(expected_res_lines_records) + .astype(expected_res_lines_dtypes) + .set_index(["line_id", "phase"]) + ) + assert_frame_equal(small_network.res_lines, expected_res_lines, rtol=1e-4, atol=1e-5) + + # Lines with violated max current + small_network.branches["line"].parameters.max_current = 0.002 + expected_res_lines_violated_records = [ + d | {"max_current": 0.002, "violated": d["phase"] != "n"} for d in expected_res_lines_records + ] + expected_res_violated_lines = ( + pd.DataFrame.from_records(expected_res_lines_violated_records) + .astype(expected_res_lines_dtypes) + .set_index(["line_id", "phase"]) + ) + assert_frame_equal(small_network.res_lines, expected_res_violated_lines, rtol=1e-4, atol=1e-5) + + # Switches results + expected_res_switches = ( + pd.DataFrame.from_records( + [], + columns=[ + "switch_id", + "phase", + "current1", + "current2", + "power1", + "power2", + "potential1", + "potential2", + ], + ) + .astype( { - "load_id": "load", - "phase": "c", - "current": -0.00250 + 0.00433j, - "power": (-9999.97499 + 17320.46477j) * (-0.00250 + 0.00433j).conjugate(), - "potential": -9999.97499 + 17320.46477j, - }, + "switch_id": object, + "phase": PhaseDtype, + "current1": complex, + "current2": complex, + "power1": complex, + "power2": complex, + "potential1": complex, + "potential2": complex, + } + ) + .set_index(["switch_id", "phase"]) + ) + assert_frame_equal(small_network.res_switches, expected_res_switches) + + # Loads results + expected_res_loads = ( + pd.DataFrame.from_records( + [ + { + "load_id": "load", + "phase": "a", + "current": 0.00500 + 7.22802e-25j, + "power": (19999.94999 + 2.89119e-18j) * (0.00500 + 7.22802e-25j).conjugate(), + "potential": 19999.94999 + 2.89119e-18j, + }, + { + "load_id": "load", + "phase": "b", + "current": -0.00250 - 0.00433j, + "power": (-9999.97499 - 17320.46477j) * (-0.00250 - 0.00433j).conjugate(), + "potential": -9999.97499 - 17320.46477j, + }, + { + "load_id": "load", + "phase": "c", + "current": -0.00250 + 0.00433j, + "power": (-9999.97499 + 17320.46477j) * (-0.00250 + 0.00433j).conjugate(), + "potential": -9999.97499 + 17320.46477j, + }, + { + "load_id": "load", + "phase": "n", + "current": -1.34763e-13 + 0j, + "power": (0j) * (-1.34763e-13 + 0j).conjugate(), + "potential": 0j, + }, + ] + ) + .astype( { - "load_id": "load", - "phase": "n", - "current": -1.34763e-13 + 0j, - "power": (0j) * (-1.34763e-13 + 0j).conjugate(), - "potential": 0j, - }, - ], - index=["load_id", "phase"], + "load_id": object, + "phase": PhaseDtype, + "current": complex, + "power": complex, + "potential": complex, + } + ) + .set_index(["load_id", "phase"]) ) - set_index_dtype(expected_res_loads, _PHASE_DTYPE) assert_frame_equal(small_network.res_loads, expected_res_loads, rtol=1e-4) - expected_res_sources = pd.DataFrame.from_records( - [ - { - "source_id": "vs", - "phase": "a", - "current": -0.00500 + 0j, - "power": (20000 + 2.89120e-18j) * (-0.00500 + 0j).conjugate(), - "potential": 20000 + 2.89120e-18j, - }, - { - "source_id": "vs", - "phase": "b", - "current": 0.00250 + 0.00433j, - "power": (-10000.00000 - 17320.50807j) * (0.00250 + 0.00433j).conjugate(), - "potential": -10000.00000 - 17320.50807j, - }, - { - "source_id": "vs", - "phase": "c", - "current": 0.00250 - 0.00433j, - "power": (-10000.00000 + 17320.50807j) * (0.00250 - 0.00433j).conjugate(), - "potential": -10000.00000 + 17320.50807j, - }, + # Sources results + expected_res_sources = ( + pd.DataFrame.from_records( + [ + { + "source_id": "vs", + "phase": "a", + "current": -0.00500 + 0j, + "power": (20000 + 2.89120e-18j) * (-0.00500 + 0j).conjugate(), + "potential": 20000 + 2.89120e-18j, + }, + { + "source_id": "vs", + "phase": "b", + "current": 0.00250 + 0.00433j, + "power": (-10000.00000 - 17320.50807j) * (0.00250 + 0.00433j).conjugate(), + "potential": -10000.00000 - 17320.50807j, + }, + { + "source_id": "vs", + "phase": "c", + "current": 0.00250 - 0.00433j, + "power": (-10000.00000 + 17320.50807j) * (0.00250 - 0.00433j).conjugate(), + "potential": -10000.00000 + 17320.50807j, + }, + { + "source_id": "vs", + "phase": "n", + "current": 1.34764e-13 - 2.89121e-19j, + "power": (-1.34764e-12 + 2.89120e-18j) * (1.34764e-13 - 2.89121e-19j).conjugate(), + "potential": -1.34764e-12 + 2.89120e-18j, + }, + ] + ) + .astype( { - "source_id": "vs", - "phase": "n", - "current": 1.34764e-13 - 2.89121e-19j, - "power": (-1.34764e-12 + 2.89120e-18j) * (1.34764e-13 - 2.89121e-19j).conjugate(), - "potential": -1.34764e-12 + 2.89120e-18j, - }, - ], - index=["source_id", "phase"], + "source_id": object, + "phase": PhaseDtype, + "current": complex, + "power": complex, + "potential": complex, + } + ) + .set_index(["source_id", "phase"]) ) - set_index_dtype(expected_res_sources, _PHASE_DTYPE) assert_frame_equal(small_network.res_sources, expected_res_sources, rtol=1e-4) - expected_res_grounds = pd.DataFrame.from_records( - [ - {"ground_id": "ground", "potential": 0j}, - ], - index=["ground_id"], + # Grounds results + expected_res_grounds = ( + pd.DataFrame.from_records( + [ + {"ground_id": "ground", "potential": 0j}, + ] + ) + .astype({"ground_id": object, "potential": complex}) + .set_index(["ground_id"]) ) assert_frame_equal(small_network.res_grounds, expected_res_grounds) - expected_res_potential_refs = pd.DataFrame.from_records( - [ - {"potential_ref_id": "pref", "current": 1.08420e-18 - 2.89120e-19j}, - ], - index=["potential_ref_id"], + # Potential refs results + expected_res_potential_refs = ( + pd.DataFrame.from_records( + [ + {"potential_ref_id": "pref", "current": 1.08420e-18 - 2.89120e-19j}, + ] + ) + .astype({"potential_ref_id": object, "current": complex}) + .set_index(["potential_ref_id"]) ) assert_frame_equal(small_network.res_potential_refs, expected_res_potential_refs) @@ -1283,27 +1834,29 @@ def set_index_dtype(df, dtype): [99.99999999999994, 0.0], ] small_network.results_from_dict(good_json_results) - expected_res_flex_powers = pd.DataFrame.from_records( - [ - { - "load_id": "load", - "phase": "an", - "power": 99.99999999999994 + 0j, - }, - { - "load_id": "load", - "phase": "bn", - "power": 99.99999999999994 + 0j, - }, - { - "load_id": "load", - "phase": "cn", - "power": 99.99999999999994 + 0j, - }, - ], - index=["load_id", "phase"], + expected_res_flex_powers = ( + pd.DataFrame.from_records( + [ + { + "load_id": "load", + "phase": "an", + "power": 99.99999999999994 + 0j, + }, + { + "load_id": "load", + "phase": "bn", + "power": 99.99999999999994 + 0j, + }, + { + "load_id": "load", + "phase": "cn", + "power": 99.99999999999994 + 0j, + }, + ] + ) + .astype({"load_id": object, "phase": VoltagePhaseDtype, "power": complex}) + .set_index(["load_id", "phase"]) ) - set_index_dtype(expected_res_flex_powers, _VOLTAGE_PHASES_DTYPE) assert_frame_equal(small_network.res_loads_flexible_powers, expected_res_flex_powers, rtol=1e-4) diff --git a/roseau/load_flow/units.py b/roseau/load_flow/units.py index 24e9212c..0c497a44 100644 --- a/roseau/load_flow/units.py +++ b/roseau/load_flow/units.py @@ -12,6 +12,7 @@ .. _pint: https://pint.readthedocs.io/en/stable/getting/overview.html """ from collections.abc import Callable, Iterable +from types import GenericAlias from typing import TYPE_CHECKING, TypeVar, Union from pint import Unit, UnitRegistry @@ -32,7 +33,7 @@ Q_: TypeAlias = PlainQuantity[T] else: Q_ = ureg.Quantity - Q_.__class_getitem__ = lambda cls, *args: cls + Q_.__class_getitem__ = classmethod(GenericAlias) def ureg_wraps( @@ -40,4 +41,16 @@ def ureg_wraps( args: Union[str, Unit, None, Iterable[Union[str, Unit, None]]], strict: bool = True, ) -> Callable[[FuncT], FuncT]: + """Wraps a function to become pint-aware. + + Args: + ureg: + a UnitRegistry instance. + ret: + Units of each of the return values. Use `None` to skip argument conversion. + args: + Units of each of the input arguments. Use `None` to skip argument conversion. + strict: + Indicates that only quantities are accepted. (Default value = True) + """ return ureg.wraps(ret, args, strict) diff --git a/roseau/load_flow/utils/__init__.py b/roseau/load_flow/utils/__init__.py index b3156715..e774d8d0 100644 --- a/roseau/load_flow/utils/__init__.py +++ b/roseau/load_flow/utils/__init__.py @@ -4,7 +4,14 @@ from roseau.load_flow.utils.console import console, palette from roseau.load_flow.utils.constants import CX, DELTA_P, EPSILON_0, EPSILON_R, MU_0, MU_R, OMEGA, PI, RHO, TAN_D, F from roseau.load_flow.utils.mixins import CatalogueMixin, Identifiable, JsonMixin -from roseau.load_flow.utils.types import ConductorType, InsulatorType, LineType +from roseau.load_flow.utils.types import ( + BranchTypeDtype, + ConductorType, + InsulatorType, + LineType, + PhaseDtype, + VoltagePhaseDtype, +) __all__ = [ # Constants @@ -27,6 +34,10 @@ "LineType", "ConductorType", "InsulatorType", + # Dtypes + "PhaseDtype", + "VoltagePhaseDtype", + "BranchTypeDtype", # Console "console", "palette", diff --git a/roseau/load_flow/utils/mixins.py b/roseau/load_flow/utils/mixins.py index 6e5c82d7..9b6af540 100644 --- a/roseau/load_flow/utils/mixins.py +++ b/roseau/load_flow/utils/mixins.py @@ -53,13 +53,9 @@ def from_json(cls, path: StrPath) -> Self: return cls.from_dict(data=data) @abstractmethod - def to_dict(self, include_geometry: bool = True) -> JsonDict: - """Return the element information as a dictionary format. - - Args: - include_geometry: - If False, the geometry will not be added to the result dictionary. - """ + def to_dict(self, *, _lf_only: bool = False) -> JsonDict: + """Return the element information as a dictionary format.""" + # _lf_only is used internally by Roseau Load Flow. Please do not use. raise NotImplementedError def to_json(self, path: StrPath) -> Path: diff --git a/roseau/load_flow/utils/types.py b/roseau/load_flow/utils/types.py index 5e2f8be0..e792f510 100644 --- a/roseau/load_flow/utils/types.py +++ b/roseau/load_flow/utils/types.py @@ -1,6 +1,7 @@ import logging from enum import Enum, auto, unique +import pandas as pd from typing_extensions import Self from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode @@ -9,6 +10,48 @@ logger = logging.getLogger(__name__) +# pandas dtypes used in the data frames +PhaseDtype = pd.CategoricalDtype(categories=["a", "b", "c", "n"], ordered=True) +"""Categorical data type used for the phase of potentials, currents, powers, etc.""" +VoltagePhaseDtype = pd.CategoricalDtype(categories=["an", "bn", "cn", "ab", "bc", "ca"], ordered=True) +"""Categorical data type used for the phase of voltages and flexible powers only.""" +BranchTypeDtype = pd.CategoricalDtype(categories=["line", "transformer", "switch"], ordered=True) +"""Categorical data type used for branch types.""" +_DTYPES = { + "bus_id": object, + "branch_id": object, + "transformer_id": object, + "line_id": object, + "switch_id": object, + "load_id": object, + "source_id": object, + "ground_id": object, + "potential_ref_id": object, + "branch_type": BranchTypeDtype, + "phase": PhaseDtype, + "current": complex, + "current1": complex, + "current2": complex, + "power": complex, + "power1": complex, + "power2": complex, + "potential": complex, + "potential1": complex, + "potential2": complex, + "voltage": complex, + "voltage1": complex, + "voltage2": complex, + "max_power": float, + "series_losses": complex, + "shunt_losses": complex, + "series_current": complex, + "max_current": float, + "min_voltage": float, + "max_voltage": float, + "violated": pd.BooleanDtype(), +} + + @unique class LineType(Enum): """The type of a line."""