From 613c085b62334cabc060b6afbfddc4cfa635fb1e Mon Sep 17 00:00:00 2001 From: Ali Hamdan Date: Tue, 7 Jan 2025 10:15:21 +0100 Subject: [PATCH] Improve line enumeration types and fix errors in Coiffier's model (#311) - Add French aliases to line enumeration types. - Fix `TypeError`s in the `LineParameters.from_coiffier_model`. The error message of invalid models now indicates whether the line type or the conductor material is invalid. - Add `TransformerCooling` enumeration. It is not currently used but the plan is to include it in the catalogue as this information is included in the catalogues we have. --- doc/Changelog.md | 3 + roseau/load_flow/io/dict.py | 23 +- roseau/load_flow/io/tests/test_dict.py | 2 +- roseau/load_flow/models/lines/parameters.py | 137 ++++++------ .../models/tests/test_line_parameters.py | 74 +++++-- roseau/load_flow/tests/test_types.py | 17 +- roseau/load_flow/types.py | 203 +++++++++++------- roseau/load_flow/utils/__init__.py | 4 +- roseau/load_flow/utils/helpers.py | 15 ++ 9 files changed, 294 insertions(+), 184 deletions(-) diff --git a/doc/Changelog.md b/doc/Changelog.md index e5897416..8afa09c2 100644 --- a/doc/Changelog.md +++ b/doc/Changelog.md @@ -19,6 +19,9 @@ og:description: See what's new in the latest release of Roseau Load Flow ! ## Unreleased +- {gh-pr}`311` Add French aliases to line enumeration types. +- {gh-pr}`311` Fix `TypeError`s in the `LineParameters.from_coiffier_model`. The error message of + invalid models now indicates whether the line type or the conductor material is invalid. - {gh-pr}`310` {gh-issue}`308` Support star and zig-zag windings with non-brought out neutral. In earlier versions, vector groups like "Yd11" were considered identical to "YNd11". - {gh-pr}`307` {gh-issue}`296` Make `line.res_violated` and `bus.res_violated` return a boolean array diff --git a/roseau/load_flow/io/dict.py b/roseau/load_flow/io/dict.py index f662cb92..7676528b 100644 --- a/roseau/load_flow/io/dict.py +++ b/roseau/load_flow/io/dict.py @@ -28,6 +28,7 @@ TransformerParameters, VoltageSource, ) +from roseau.load_flow.types import Insulator, Material from roseau.load_flow.typing import Id, JsonDict from roseau.load_flow.utils import find_stack_level @@ -700,10 +701,10 @@ def v2_to_v3_converter(data: JsonDict) -> JsonDict: # noqa: C901 transformers_params_max_loading[transformer_param_data["id"]] = loading transformers_params.append(transformer_param_data) - # Rename `maximal_current` in `ampacities` and uses array - # Rename `section` in `sections` and uses array - # Rename `insulator_type` in `insulators` and uses array. `Unknown` is deleted - # Rename `material` in `materials` and uses array + # Rename `maximal_current` to `ampacities` and use array + # Rename `section` to `sections` and use array + # Rename `insulator_type` to `insulators` and use array. `Unknown` is deleted + # Rename `material` to `materials` and use array old_lines_params = data.get("lines_params", []) lines_params = [] for line_param_data in old_lines_params: @@ -770,9 +771,8 @@ def v3_to_v4_converter(data: JsonDict) -> JsonDict: for tr in data["transformers"]: tr_phases_per_params[tr["params_id"]].append((tr["phases1"], tr["phases2"])) - old_transformer_params = data["transformers_params"] transformer_params = [] - for tp_data in old_transformer_params: + for tp_data in data["transformers_params"]: w1, w2, clock = TransformerParameters.extract_windings(tp_data["vg"]) # Handle brought out neutrals that were not declared as such if w1 in ("Y", "Z") and any(tr_phases[0] == "abcn" for tr_phases in tr_phases_per_params[tp_data["id"]]): @@ -783,6 +783,15 @@ def v3_to_v4_converter(data: JsonDict) -> JsonDict: tp_data["vg"] = f"{w1}{w2}{clock}" transformer_params.append(tp_data) + line_params = [] + for line_param_data in data["lines_params"]: + # Normalize the insulator and material types + if (materials := line_param_data.pop("materials", None)) is not None: + line_param_data["materials"] = [Material(material).name for material in materials] + if (insulators := line_param_data.pop("insulators", None)) is not None: + line_param_data["insulators"] = [Insulator(insulator).name for insulator in insulators] + line_params.append(line_param_data) + results = { "version": 4, "is_multiphase": data["is_multiphase"], # Unchanged @@ -794,7 +803,7 @@ def v3_to_v4_converter(data: JsonDict) -> JsonDict: "transformers": data["transformers"], # <---- Unchanged "loads": data["loads"], # Unchanged "sources": data["sources"], # Unchanged - "lines_params": data["lines_params"], # <---- Unchanged + "lines_params": line_params, # <---- Changed "transformers_params": transformer_params, # <---- Changed } if "short_circuits" in data: diff --git a/roseau/load_flow/io/tests/test_dict.py b/roseau/load_flow/io/tests/test_dict.py index 435ed327..f88baec7 100644 --- a/roseau/load_flow/io/tests/test_dict.py +++ b/roseau/load_flow/io/tests/test_dict.py @@ -139,7 +139,7 @@ def test_to_dict(): lp_dict = res["lines_params"][0] assert np.allclose(lp_dict["ampacities"], 1000) assert lp_dict["line_type"] == "UNDERGROUND" - assert lp_dict["materials"] == ["AA"] * 4 + assert lp_dict["materials"] == ["ACSR"] * 4 assert lp_dict["insulators"] == ["PVC"] * 4 assert np.allclose(lp_dict["sections"], 120) assert "results" not in res_bus0 diff --git a/roseau/load_flow/models/lines/parameters.py b/roseau/load_flow/models/lines/parameters.py index 7154c354..fed68e13 100644 --- a/roseau/load_flow/models/lines/parameters.py +++ b/roseau/load_flow/models/lines/parameters.py @@ -50,14 +50,6 @@ class LineParameters(Identifiable, JsonMixin, CatalogueMixin[pd.DataFrame]): """Parameters that define electrical models of lines.""" - _type_re = "|".join(x.code() for x in LineType) - _material_re = "|".join(x.code() for x in Material) - _insulator_re = "|".join(x.code() for x in Insulator) - _section_re = r"[1-9][0-9]*" - _REGEXP_LINE_TYPE_NAME = re.compile( - rf"^({_type_re})_({_material_re})_({_insulator_re}_)?{_section_re}$", flags=re.IGNORECASE - ) - @ureg_wraps(None, (None, None, "ohm/km", "S/km", "A", None, None, None, "mm²")) def __init__( self, @@ -142,11 +134,11 @@ def __init__( def __repr__(self) -> str: s = f"<{type(self).__name__}: id={self.id!r}" if self._line_type is not None: - s += f", line_type={str(self._line_type)!r}" + s += f", line_type='{self._line_type!s}'" if self._insulators is not None: - s += f", insulators={self._insulators}" + s += f", insulators='{self._insulators!s}'" if self._materials is not None: - s += f", materials={self._materials}" + s += f", materials='{self._materials!s}'" if self._sections is not None: s += f", sections={self._sections}" if self._ampacities is not None: @@ -471,11 +463,11 @@ def from_geometry( cls, id: Id, *, - line_type: LineType, - material: Material | None = None, - material_neutral: Material | None = None, - insulator: Insulator | None = None, - insulator_neutral: Insulator | None = None, + line_type: LineType | str, + material: Material | str | None = None, + material_neutral: Material | str | None = None, + insulator: Insulator | str | None = None, + insulator_neutral: Insulator | str | None = None, section: float | Q_[float], section_neutral: float | Q_[float] | None = None, height: float | Q_[float], @@ -565,11 +557,11 @@ def from_geometry( def _from_geometry( cls, id: Id, - line_type: LineType, - material: Material | None, - material_neutral: Material | None, - insulator: Insulator | None, - insulator_neutral: Insulator | None, + line_type: LineType | str, + material: Material | str | None, + material_neutral: Material | str | None, + insulator: Insulator | str | None, + insulator_neutral: Insulator | str | None, section: float, section_neutral: float | None, height: float, @@ -623,23 +615,14 @@ def _from_geometry( # dpn = data["dpn"] # Distance phase-to-neutral (m) # dsh = data["dsh"] # Diameter of the sheath (mm) + # Normalize enumerations and fill optional values line_type = LineType(line_type) - if material is None: - material = _DEFAULT_MATERIAL[line_type] - if insulator is None: - insulator = _DEFAULT_INSULATOR[line_type] - if material_neutral is None: - material_neutral = material - if insulator_neutral is None: - insulator_neutral = insulator + material = _DEFAULT_MATERIAL[line_type] if material is None else Material(material) + insulator = _DEFAULT_INSULATOR[line_type] if insulator is None else Insulator(insulator) + material_neutral = material if material_neutral is None else Material(material_neutral) + insulator_neutral = insulator if insulator_neutral is None else Insulator(insulator_neutral) if section_neutral is None: section_neutral = section - material = Material(material) - material_neutral = Material(material_neutral) - if insulator is not None: - insulator = Insulator(insulator) - if insulator_neutral is not None: - insulator_neutral = Insulator(insulator_neutral) # Geometric configuration coord, coord_prim, epsilon, epsilon_neutral = cls._get_geometric_configuration( @@ -728,8 +711,8 @@ def _from_geometry( @staticmethod def _get_geometric_configuration( line_type: LineType, - insulator: Insulator | None, - insulator_neutral: Insulator | None, + insulator: Insulator, + insulator_neutral: Insulator, height: float, external_diameter: float, ) -> tuple[FloatArray, FloatArray, float, float]: @@ -762,7 +745,7 @@ def _get_geometric_configuration( if line_type in (LineType.OVERHEAD, LineType.TWISTED): # TODO This configuration is for twisted lines... Create a overhead configuration. if height <= 0: - msg = f"The height of a '{line_type}' line must be a positive number." + msg = f"The height of '{line_type}' line must be a positive number." logger.error(msg) raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_LINE_MODEL) x = SQRT3 * external_diameter / 8 @@ -786,7 +769,7 @@ def _get_geometric_configuration( epsilon_neutral = EPSILON_0.m # TODO assume no insulator. Maybe valid for overhead but not for twisted... elif line_type == LineType.UNDERGROUND: if height >= 0: - msg = f"The height of a '{line_type}' line must be a negative number." + msg = f"The height of '{line_type}' line must be a negative number." logger.error(msg) raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_LINE_MODEL) x = np.sqrt(2) * external_diameter / 8 @@ -796,9 +779,7 @@ def _get_geometric_configuration( epsilon = (EPSILON_0 * EPSILON_R[insulator]).m epsilon_neutral = (EPSILON_0 * EPSILON_R[insulator_neutral]).m else: - msg = f"The line type {line_type!r} of the line {id!r} is unknown." - logger.error(msg) - raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_LINE_TYPE) + raise NotImplementedError(line_type) # unreachable return coord, coord_prim, epsilon, epsilon_neutral @@ -809,7 +790,8 @@ def from_coiffier_model(cls, name: str, nb_phases: int = 3, id: Id | None = None Args: name: The canonical name of the line parameters. It must be in the format - `LineType_Material_CrossSection`. E.g. "U_AL_150". + `LineType_Material_CrossSection` (e.g. "S_AL_150") or + `LineType_Material_Insulator_CrossSection` (e.g. "S_AL_PE_150"). nb_phases: The number of phases of the line between 1 and 4, defaults to 3. It represents the @@ -823,30 +805,44 @@ def from_coiffier_model(cls, name: str, nb_phases: int = 3, id: Id | None = None The corresponding line parameters. """ # Check the user input and retrieve enumerated types + m = re.match( + r"^(?P[a-z]+)_(?P[a-z]+)_(?:(?P[a-z]+)_)?(?P
[1-9][0-9]*)$", + name, + flags=re.IGNORECASE, + ) try: - if cls._REGEXP_LINE_TYPE_NAME.fullmatch(string=name) is None: + if m is None: raise AssertionError - line_type_s, material_s, section_s = name.split("_") - line_type = LineType(line_type_s) - material = Material(material_s) - section = Q_(float(section_s), "mm**2") - except Exception: + matches = m.groupdict() + line_type = LineType(matches["line_type"]) + material = Material(matches["material"]) + insulator = Insulator(matches["insulator"]) if matches["insulator"] is not None else None + section = Q_(float(matches["section"]), "mm**2") + except Exception as e: msg = ( f"The Coiffier line parameter name {name!r} is not valid, expected format is " - "'LineType_Material_CrossSection'." + "'LineType_Material_CrossSection' or 'LineType_Material_Insulator_CrossSection'" ) + if m is not None: + msg += f": {e}" logger.error(msg) raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_TYPE_NAME_SYNTAX) from None - + if insulator is not None: + # TODO: add insulator support + warnings.warn( + f"The insulator is currently ignored in the Coiffier model, got '{insulator.upper()}'.", + category=UserWarning, + stacklevel=find_stack_level(), + ) r = RHO[material] / section if line_type == LineType.OVERHEAD: c_b1 = Q_(50, "µF/km") c_b2 = Q_(0, "µF/(km*mm**2)") x = Q_(0.35, "ohm/km") if material == Material.AA: - if section <= 50: + if section <= Q_(50, "mm**2"): c_imax = 14.20 - elif 50 < section <= 100: + elif section <= Q_(100, "mm**2"): c_imax = 12.10 else: c_imax = 15.70 @@ -855,14 +851,14 @@ def from_coiffier_model(cls, name: str, nb_phases: int = 3, id: Id | None = None elif material == Material.CU: c_imax = 21 elif material == Material.LA: - if section <= 50: + if section <= Q_(50, "mm**2"): c_imax = 13.60 - elif 50 < section <= 100: + elif section <= Q_(100, "mm**2"): c_imax = 12.10 else: c_imax = 15.60 else: - c_imax = 15.90 + c_imax = 15.90 # pragma: no-cover # unreachable elif line_type == LineType.TWISTED: c_b1 = Q_(1750, "µF/km") c_b2 = Q_(5, "µF/(km*mm**2)") @@ -883,9 +879,7 @@ def from_coiffier_model(cls, name: str, nb_phases: int = 3, id: Id | None = None c_imax = 16.5 x = Q_(0.1, "ohm/km") else: - msg = f"The line type {line_type!r} of the line {name!r} is unknown." - logger.error(msg) - raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode.BAD_LINE_TYPE) + raise NotImplementedError(line_type) # unreachable b = (c_b1 + c_b2 * section) * 1e-4 * OMEGA b = b.to("S/km") @@ -1329,7 +1323,7 @@ def _get_catalogue( ) try: mask = enum_series == enum_class(value) - except RoseauLoadFlowException: + except ValueError: mask = pd.Series(data=False, index=catalogue_data.index) if raise_if_not_found and mask.sum() == 0: cls._raise_not_found_in_catalogue( @@ -1669,14 +1663,23 @@ def _check_matrix(self) -> None: @staticmethod def _check_enum_array( - value: _StrEnumType | Sequence[_StrEnumType] | None, - enum_class: type[_StrEnumType], + value: str | Sequence[str] | NDArray | None, + enum_class: type[StrEnum], name: Literal["insulators", "materials"], size: int, - ) -> NDArray[_StrEnumType] | None: + ) -> NDArray[np.object_] | None: value_isna = pd.isna(value) + + def convert(v): + try: + return enum_class(v) + except ValueError as e: + raise RoseauLoadFlowException( + msg=str(e), code=RoseauLoadFlowExceptionCode[f"BAD_{enum_class.__name__.upper()}"] + ) from None + if np.isscalar(value_isna): - return None if value_isna else np.array([enum_class(value) for _ in range(size)], dtype=np.object_) + return None if value_isna else np.array([convert(value) for _ in range(size)], dtype=np.object_) elif np.all(value_isna): return None else: @@ -1686,9 +1689,9 @@ def _check_enum_array( raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode[f"BAD_{name.upper()}_VALUE"]) # Build the numpy array fails with pd.NA inside - values = np.array([enum_class(v) for v in value], dtype=np.object_) - if len(value) != size: - msg = f"Incorrect number of {name}: {len(value)} instead of {size}." + values = np.array([convert(v) for v in value], dtype=np.object_) + if len(values) != size: + msg = f"Incorrect number of {name}: {len(values)} instead of {size}." logger.error(msg) raise RoseauLoadFlowException(msg=msg, code=RoseauLoadFlowExceptionCode[f"BAD_{name.upper()}_SIZE"]) return values diff --git a/roseau/load_flow/models/tests/test_line_parameters.py b/roseau/load_flow/models/tests/test_line_parameters.py index a5f9a5b4..ccfaa7a2 100644 --- a/roseau/load_flow/models/tests/test_line_parameters.py +++ b/roseau/load_flow/models/tests/test_line_parameters.py @@ -360,6 +360,51 @@ def test_from_geometry(): npt.assert_allclose(y_shunt, y_shunt_expected) +def test_from_geometry_checks(): + # Wrong height + with pytest.raises(RoseauLoadFlowException) as e: + LineParameters.from_geometry( + "test", line_type="O", material="AL", section=150, height=-1.5, external_diameter=0.04 + ) + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_MODEL + assert e.value.msg == "The height of 'overhead' line must be a positive number." + with pytest.raises(RoseauLoadFlowException) as e: + LineParameters.from_geometry( + "test", line_type="U", material="AL", section=150, height=15, external_diameter=0.00001 + ) + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_MODEL + assert e.value.msg == "The height of 'underground' line must be a negative number." + + # Wrong external diameter + with pytest.raises(RoseauLoadFlowException) as e: + LineParameters.from_geometry( + "test", line_type="T", material="AL", section=150, height=15, external_diameter=0.02 + ) + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_MODEL + assert e.value.msg == ( + "Conductors too big for 'twisted' line parameter of id 'test'. Inequality " + "`neutral_radius + phase_radius <= external_diameter / 4` is not satisfied." + ) + with pytest.raises(RoseauLoadFlowException) as e: + LineParameters.from_geometry( + "test", line_type="U", material="AL", section=150, height=-1.5, external_diameter=0.02 + ) + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_MODEL + assert e.value.msg == ( + "Conductors too big for 'underground' line parameter of id 'test'. Inequality " + "`neutral_radius + phase_radius <= external_diameter * sqrt(2) / 4` is not satisfied." + ) + with pytest.raises(RoseauLoadFlowException) as e: + LineParameters.from_geometry( + "test", line_type="U", material="AL", section=150, section_neutral=50, height=-1.5, external_diameter=0.035 + ) + assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_MODEL + assert e.value.msg == ( + "Conductors too big for 'underground' line parameter of id 'test'. Inequality " + "`phase_radius*2 <= external_diameter * sqrt(2) / 4` is not satisfied." + ) + + def test_sym(): # With the bad model of PwF # line_data = {"id": "NKBA NOR 25.00 kV", "un": 25000.0, "in": 277.0000100135803} @@ -451,15 +496,10 @@ def test_from_coiffier_model(): assert e.value.code == RoseauLoadFlowExceptionCode.BAD_TYPE_NAME_SYNTAX assert e.value.msg == ( "The Coiffier line parameter name 'totoU_Al_150' is not valid, expected format is " - "'LineType_Material_CrossSection'." + "'LineType_Material_CrossSection' or 'LineType_Material_Insulator_CrossSection': 'totoU' is not a valid LineType" ) - with pytest.raises(RoseauLoadFlowException) as e: + with pytest.warns(UserWarning, match=r"The insulator is currently ignored in the Coiffier model, got 'IP'."): LineParameters.from_coiffier_model("U_AL_IP_150") - assert e.value.code == RoseauLoadFlowExceptionCode.BAD_TYPE_NAME_SYNTAX - assert e.value.msg == ( - "The Coiffier line parameter name 'U_AL_IP_150' is not valid, expected format is " - "'LineType_Material_CrossSection'." - ) # Working example with defaults lp = LineParameters.from_coiffier_model("U_AL_150") @@ -482,6 +522,13 @@ def test_from_coiffier_model(): assert lp2.z_line.m.shape == (2, 2) assert lp2.y_shunt.m.shape == (2, 2) + # Check that no errors are raised with all the possible combinations + for lt in LineType: + for m in Material: + # TODO for i in Insulator: + for s in (40, 80, 120): + LineParameters.from_coiffier_model(f"{lt.name}_{m.name}_{s}") + def test_catalogue_data(): # The catalogue data path exists @@ -494,6 +541,7 @@ def test_catalogue_data(): assert catalogue_data["name"].is_unique, "Regenerate catalogue." for row in catalogue_data.itertuples(): + assert isinstance(row.name, str) assert re.match(r"^[UOT]_[A-Z]+_\d+(?:_\w+)?$", row.name) assert isinstance(row.resistance, float) assert isinstance(row.resistance_neutral, float) @@ -506,8 +554,10 @@ def test_catalogue_data(): LineType(row.type) # Check that the type is valid Material(row.material) # Check that the material is valid Material(row.material_neutral) # Check that the material is valid - pd.isna(row.insulator) or Insulator(row.insulator) # Check that the insulator is valid - pd.isna(row.insulator_neutral) or Insulator(row.insulator_neutral) # Check that the insulator is valid + if pd.notna(row.insulator): + Insulator(row.insulator) # Check that the insulator is valid + if pd.notna(row.insulator_neutral): + Insulator(row.insulator_neutral) # Check that the insulator is valid assert isinstance(row.section, int | float) assert isinstance(row.section_neutral, int | float) @@ -673,7 +723,7 @@ def test_insulators(): with pytest.raises(RoseauLoadFlowException) as e: lp.insulators = ["invalid", Insulator.XLPE, "XLPE"] - assert e.value.msg == "'invalid' cannot be converted into a Insulator." + assert e.value.msg == "'invalid' is not a valid Insulator" assert e.value.code == RoseauLoadFlowExceptionCode.BAD_INSULATOR with pytest.raises(RoseauLoadFlowException) as e: @@ -715,12 +765,12 @@ def test_materials(): # Errors with pytest.raises(RoseauLoadFlowException) as e: lp.materials = [np.nan, float("nan"), Material.AM] - assert e.value.msg == "Materials cannot contain null values: [nan, nan, am] was provided." + assert e.value.msg == "Materials cannot contain null values: [nan, nan, aaac] was provided." assert e.value.code == RoseauLoadFlowExceptionCode.BAD_MATERIALS_VALUE with pytest.raises(RoseauLoadFlowException) as e: lp.materials = ["invalid", Material.AM, "AM"] - assert e.value.msg == "'invalid' cannot be converted into a Material." + assert e.value.msg == "'invalid' is not a valid Material" assert e.value.code == RoseauLoadFlowExceptionCode.BAD_MATERIAL with pytest.raises(RoseauLoadFlowException) as e: diff --git a/roseau/load_flow/tests/test_types.py b/roseau/load_flow/tests/test_types.py index 0e7203f3..1d1b5757 100644 --- a/roseau/load_flow/tests/test_types.py +++ b/roseau/load_flow/tests/test_types.py @@ -1,7 +1,6 @@ import pytest from roseau.load_flow.constants import DELTA_P, EPSILON_R, MU_R, RHO, TAN_D -from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode from roseau.load_flow.types import Insulator, LineType, Material TYPES = [Material, Insulator, LineType] @@ -27,14 +26,10 @@ def test_types_basic(t): def test_line_type(): - with pytest.raises(RoseauLoadFlowException) as e: + with pytest.raises(ValueError, match=r"is not a valid LineType"): LineType("") - assert "cannot be converted into a LineType" in e.value.msg - assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_TYPE - with pytest.raises(RoseauLoadFlowException) as e: + with pytest.raises(ValueError, match=r"is not a valid LineType"): LineType("nan") - assert "cannot be converted into a LineType" in e.value.msg - assert e.value.code == RoseauLoadFlowExceptionCode.BAD_LINE_TYPE assert LineType("oVeRhEaD") == LineType.OVERHEAD assert LineType("o") == LineType.OVERHEAD @@ -49,11 +44,7 @@ def test_insulator(): def test_material(): - with pytest.raises(RoseauLoadFlowException) as e: + with pytest.raises(ValueError, match=r"is not a valid Material"): Material("") - assert "cannot be converted into a Material" in e.value.msg - assert e.value.code == RoseauLoadFlowExceptionCode.BAD_MATERIAL - with pytest.raises(RoseauLoadFlowException) as e: + with pytest.raises(ValueError, match=r"is not a valid Material"): Material("nan") - assert "cannot be converted into a Material" in e.value.msg - assert e.value.code == RoseauLoadFlowExceptionCode.BAD_MATERIAL diff --git a/roseau/load_flow/types.py b/roseau/load_flow/types.py index a648ecd7..59ca2e5f 100644 --- a/roseau/load_flow/types.py +++ b/roseau/load_flow/types.py @@ -1,14 +1,13 @@ import logging from enum import auto -from roseau.load_flow._compat import StrEnum -from roseau.load_flow.exceptions import RoseauLoadFlowException, RoseauLoadFlowExceptionCode +from roseau.load_flow.utils import CaseInsensitiveStrEnum # The local logger logger = logging.getLogger(__name__) -class LineType(StrEnum): +class LineType(CaseInsensitiveStrEnum): """The type of a line.""" OVERHEAD = auto() @@ -18,78 +17,55 @@ class LineType(StrEnum): TWISTED = auto() """A twisted line commonly known as Aerial Cable or Aerial Bundled Conductor (ABC) -- Fr = Torsadé.""" - # aliases + # Short aliases O = OVERHEAD # noqa: E741 U = UNDERGROUND T = TWISTED - @classmethod - def _missing_(cls, value: object) -> "LineType | None": - if isinstance(value, str): - try: - return cls[value.upper()] - except KeyError: - pass - msg = f"{value!r} cannot be converted into a LineType." - logger.error(msg) - raise RoseauLoadFlowException(msg, RoseauLoadFlowExceptionCode.BAD_LINE_TYPE) + # French aliases + AERIEN = OVERHEAD # Aérien + A = OVERHEAD # Aérien + SOUTERRAIN = UNDERGROUND # Souterrain + S = UNDERGROUND # Souterrain + TORSADE = TWISTED # Torsadé - def code(self) -> str: - """A code that can be used in line type names.""" - return self.name[0] - -class Material(StrEnum): +class Material(CaseInsensitiveStrEnum): """The type of the material of the conductor.""" + # AAC: 1350-H19 (Standard Round of Compact Round) + # AAC/TW: 1380-H19 (Trapezoidal Wire) + # AAAC: Aluminum alloy 6201-T81. + # AAAC: Concentric-lay-stranded + # AAAC: conforms to ASTM Specification B-399 + # AAAC: Applications: Overhead + # ACSR: Aluminum alloy 1350-H-19 + # ACSR: Applications: Bare overhead transmission cable and primary and secondary distribution cable + CU = auto() """Copper -- Fr = Cuivre.""" - AL = auto() - """All Aluminum Conductor (AAC) -- Fr = Aluminium.""" - AM = auto() - """All Aluminum Alloy Conductor (AAAC) -- Fr = Almélec.""" - AA = auto() - """Aluminum Conductor Steel Reinforced (ACSR) -- Fr = Alu-Acier.""" - LA = auto() - """Aluminum Alloy Conductor Steel Reinforced (AACSR) -- Fr = Almélec-Acier.""" - - # Aliases - AAC = AL # 1350-H19 (Standard Round of Compact Round) - """All Aluminum Conductor (AAC) -- Fr = Aluminium.""" - # AAC/TW # 1380-H19 (Trapezoidal Wire) - - AAAC = AM - """All Aluminum Alloy Conductor (AAAC) -- Fr = Almélec.""" - # Aluminum alloy 6201-T81. - # Concentric-lay-stranded - # conforms to ASTM Specification B-399 - # Applications: Overhead - - ACSR = AA - """Aluminum Conductor Steel Reinforced (ACSR) -- Fr = Alu-Acier.""" - # Aluminum alloy 1350-H-19 - # Applications: Bare overhead transmission cable and primary and secondary distribution cable - - AACSR = LA - """Aluminum Alloy Conductor Steel Reinforced (AACSR) -- Fr = Almélec-Acier.""" - - @classmethod - def _missing_(cls, value: object) -> "Material": - if isinstance(value, str): - try: - return cls[value.upper()] - except KeyError: - pass - msg = f"{value!r} cannot be converted into a Material." - logger.error(msg) - raise RoseauLoadFlowException(msg, RoseauLoadFlowExceptionCode.BAD_MATERIAL) - - def code(self) -> str: - """A code that can be used in conductor type names.""" - return self.name - - -class Insulator(StrEnum): + AAC = auto() + """All Aluminum Conductor (AAC) -- Fr = Aluminium (AL).""" + AAAC = auto() + """All Aluminum Alloy Conductor (AAAC) -- Fr = Almélec (AM, AMC).""" + ACSR = auto() + """Aluminum Conductor Steel Reinforced (ACSR) -- Fr = Alu-Acier (AA).""" + AACSR = auto() + """Aluminum Alloy Conductor Steel Reinforced (AACSR) -- Fr = Almélec-Acier (LA).""" + + # French aliases + CUC = CU # Cuivre Câble + CUF = CU # Cuivre Fil + AL = AAC # Aluminium + AM = AAAC # Almélec + AMC = AAAC # Almélec + AA = ACSR # Aluminium Acier + AR = ACSR # Aluminium Acier Renforcé + LA = AACSR # Almélec Acier + LR = AACSR # Almélec Acier Renforcé + + +class Insulator(CaseInsensitiveStrEnum): """The type of the insulator for a wire.""" NONE = auto() @@ -111,23 +87,84 @@ class Insulator(StrEnum): IP = auto() """Impregnated Paper (IP) insulation.""" - # Aliases + # French aliases PEX = XLPE - """Alias -- Cross-linked polyethylene (XLPE) insulation.""" PE = MDPE - """Alias -- Medium-Density PolyEthylene (MDPE) insulation.""" - - @classmethod - def _missing_(cls, value: object) -> "Insulator": - if isinstance(value, str): - try: - return cls[value.upper()] - except KeyError: - pass - msg = f"{value!r} cannot be converted into a Insulator." - logger.error(msg) - raise RoseauLoadFlowException(msg, RoseauLoadFlowExceptionCode.BAD_INSULATOR) - - def code(self) -> str: - """A code that can be used in insulator type names.""" - return self.name + + +class TransformerCooling(CaseInsensitiveStrEnum): + """IEC Designations and Descriptions of the Cooling Classes Used in Power Transformers.""" + + # TODO add to the catalogue + + ONAN = auto() + """Oil Natural/Air Natural. + + Oil-air (self-cooled). + + Previous designation (1993): OA or ONS + """ + ONAF = auto() + """Oil Natural/Air Forced, Forced-air + + Previous designation (1993): FA or ONF + """ + ONAN_ONAF_ONAF = auto() + """Oil Natural Air Natural/Oil Natural Air Forced/Oil Natural Air Forced. + + Oil-air (self-cooled), followed by two stages of forced-air cooling (fans). + + Previous designation (1993): OA/FA/FA + """ + ONAN_ONAF_OFAF = auto() + """Oil Natural Air Natural/Oil Natural Air Forced/Oil Forced Air Forced. + + Oil-air (self-cooled), followed by one stage of forced-air cooling (fans), followed by 1 stage + of forced oil (oil pumps). + + Previous designation (1993): OA/FA/FOA + """ + ONAF_ODAF = auto() + """Oil Natural Air Forced/Oil Direct Air Forced. + + Oil-air (self-cooled), followed by one stage of directed oil flow pumps (with fans). + + Previous designation (1993): OA/FOA + """ + ONAF_ODAF_ODAF = auto() + """Oil Natural Air Forced/Oil Direct Air Forced/Oil Direct Air Forced. + + Oil-air (self-cooled), followed by two stages of directed oil flow pumps (with fans). + + Previous designation (1993): OA/FOA/FOA + """ + OFAF = auto() + """Oil Forced Air Forced. + + Forced oil/air (with fans) rating only -- no self-cooled rating. + + Previous designation (1993): FOA + """ + OFWF = auto() + """Oil Forced Water Forced. + + Forced oil/water cooled rating only (oil/water heat exchanger with oil and water pumps) -- no + self-cooled rating. + + Previous designation (1993): FOW + """ + ODAF = auto() + """Oil Direct Air Forced + + Forced oil/air cooled rating only with directed oil flow pumps and fans -- no self-cooled rating. + + Previous designation (1993): FOA + """ + ODWF = auto() + """Oil Direct Water Forced + + Forced oil/water cooled rating only (oil/water heat exchanger with directed oil flow pumps and + water pumps) -- no self-cooled rating. + + Previous designation (1993): FOW + """ diff --git a/roseau/load_flow/utils/__init__.py b/roseau/load_flow/utils/__init__.py index 1199c4b9..d8628e86 100644 --- a/roseau/load_flow/utils/__init__.py +++ b/roseau/load_flow/utils/__init__.py @@ -11,7 +11,7 @@ VoltagePhaseDtype, ) from roseau.load_flow.utils.exceptions import find_stack_level -from roseau.load_flow.utils.helpers import count_repr +from roseau.load_flow.utils.helpers import CaseInsensitiveStrEnum, count_repr from roseau.load_flow.utils.log import set_logging_config from roseau.load_flow.utils.mixins import CatalogueMixin, Identifiable, JsonMixin from roseau.load_flow.utils.versions import show_versions @@ -36,6 +36,8 @@ "set_logging_config", # General purpose "count_repr", + # Enums + "CaseInsensitiveStrEnum", ] diff --git a/roseau/load_flow/utils/helpers.py b/roseau/load_flow/utils/helpers.py index 58c06579..0ae12270 100644 --- a/roseau/load_flow/utils/helpers.py +++ b/roseau/load_flow/utils/helpers.py @@ -1,5 +1,7 @@ from collections.abc import Sized +from roseau.load_flow._compat import StrEnum + def count_repr(items: Sized, /, singular: str, plural: str | None = None) -> str: """Singular/plural count representation: `1 bus` or `2 buses`.""" @@ -7,3 +9,16 @@ def count_repr(items: Sized, /, singular: str, plural: str | None = None) -> str if n == 1: return f"{n} {singular}" return f"{n} {plural if plural is not None else singular + 's'}" + + +class CaseInsensitiveStrEnum(StrEnum): + """A case-insensitive string enumeration.""" + + @classmethod + def _missing_(cls, value: object) -> object: + if isinstance(value, str): + try: + return cls[value.upper()] + except KeyError: + pass + return None