Source code for VeraGridEngine.Devices.Branches.tap_changer

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0

import numpy as np
import pandas as pd
from typing import Union, Dict
from VeraGridEngine.Utils.NumericalMethods.common import find_closest_number
from VeraGridEngine.enumerations import TapChangerTypes
from VeraGridEngine.basic_structures import Logger


[docs] class TapChanger: """ Tap changer """ __slots__ = ( '_asymmetry_angle', '_total_positions', '_dV', '_neutral_position', '_normal_position', '_tap_position', '_tc_type', '_low_step', '_negative_low', '_ndv', '_tau_array', '_m_array', '_k_re_array', '_k_im_array', ) def __init__(self, total_positions: int = 5, neutral_position: int = 2, normal_position: int = 2, dV: float = 0.01, asymmetry_angle: float = 90.0, tc_type: TapChangerTypes = TapChangerTypes.NoRegulation) -> None: """ Tap changer :param total_positions: Total number of positions :param neutral_position: Neutral position :param dV: per unit of voltage increment (p.u.) :param asymmetry_angle: Asymmetry angle (deg) :param tc_type: Tap changer type """ neutral_position = int(neutral_position) total_positions = int(total_positions) if neutral_position >= total_positions: neutral_position = total_positions - 1 print(f"Neutral position exceeding the total positions {neutral_position} >= {total_positions}") # asymmetry angle (Theta) self._asymmetry_angle = float(asymmetry_angle) # total number of positions self._total_positions = int(total_positions) if total_positions > 0 else 1 # voltage increment in p.u. self._dV = float(dV) # neutral position self._neutral_position = int(neutral_position) # normal position self._normal_position = int(normal_position) # index with respect to the neutral position self._tap_position = int(neutral_position) # tap changer mode self._tc_type: TapChangerTypes = tc_type # original CGMES low step, when this tap changer comes from CGMES self._low_step = 0 # for CGMES compatibility we store if the low step is negative self._negative_low = False # Calculated arrays self._ndv = np.zeros(self._total_positions) # increment of voltage positions self._tau_array = np.zeros(self._total_positions) # tap phase positions self._m_array = np.zeros(self._total_positions) # tap module positions self._k_re_array = np.ones(self._total_positions) # impedance correction positions (real) self._k_im_array = np.ones(self._total_positions) # impedance correction positions (imag) self.recalc()
[docs] def copy(self) -> "TapChanger": """ :return: """ elm = TapChanger( total_positions=self._total_positions, neutral_position=self._neutral_position, normal_position=self._normal_position, dV=self._dV, asymmetry_angle=self._asymmetry_angle, tc_type=self._tc_type ) elm._low_step = self._low_step elm._negative_low = self._negative_low elm._tap_position = self._tap_position elm._k_re_array = self._k_re_array.copy() elm._k_im_array = self._k_im_array.copy() elm.recalc() return elm
@property def asymmetry_angle(self) -> float: return self._asymmetry_angle @asymmetry_angle.setter def asymmetry_angle(self, asymmetry_angle: float) -> None: self._asymmetry_angle = float(asymmetry_angle) self.recalc() @property def dV(self) -> float: return self._dV @dV.setter def dV(self, dV: float) -> None: self._dV = float(dV) self.recalc() @property def normal_position(self) -> int: return self._normal_position @normal_position.setter def normal_position(self, normal_position: int) -> None: self._normal_position = int(normal_position) self.recalc() @property def tc_type(self) -> TapChangerTypes: return self._tc_type @tc_type.setter def tc_type(self, tc_type: TapChangerTypes) -> None: self._tc_type = tc_type self.recalc() @property def total_positions(self) -> int: """ Tap changer total number of positions :return: int """ return self._total_positions @total_positions.setter def total_positions(self, value: int): if isinstance(value, int): self._total_positions = value self.resize() else: raise TypeError(f'Expected int but got {type(value)}') @property def tap_position(self) -> int: """ Get the tap position :return: int """ return self._tap_position @tap_position.setter def tap_position(self, val: int): """ Set the tap position (zero indexing) :param val: tap value """ if val < self._total_positions: self._tap_position = int(val) self.recalc() else: print(f"Max tap changer value exceeded {val} > {self._total_positions}") @property def neutral_position(self) -> int: """ Get the neutral position :return: int """ return self._neutral_position @neutral_position.setter def neutral_position(self, val: int): """ Set the neutral position :param val: neutral position value """ self._neutral_position = int(val) self.recalc() @property def tap_modules_array(self): """ Get the tap modules array :return: """ return self._m_array @property def tap_angles_array(self): """ :return: """ return self._tau_array @property def impedance_correction_imag_array(self) -> np.ndarray: """ Get the imaginary impedance correction factors per tap position. A value of 1.0 at every position means no correction (default). :return: array of length total_positions """ return self._k_im_array
[docs] def resize(self) -> None: """ Resize and recalc the tap positions array """ self._ndv = np.zeros(self.total_positions) self._tau_array = np.zeros(self.total_positions) self._m_array = np.zeros(self.total_positions) self.recalc()
[docs] def recalc(self) -> None: """ Recalculate the phase and modules corresponding to each tap position """ positions = np.arange(self.total_positions) self._ndv = (positions - self.neutral_position) * self.dV self._tau_array = self.get_tap_phase2(positions) self._m_array = self.get_tap_module2(positions)
[docs] def to_dict(self) -> Dict[str, Union[str, float]]: """ Get a dictionary representation of the tap :return: """ return { "asymmetry_angle": self.asymmetry_angle, "total_positions": self.total_positions, "dV": self.dV, "neutral_position": self.neutral_position, "normal_position": self.normal_position, "tap_position": self._tap_position, "type": str(self.tc_type), "low_step": self._low_step, "negative_low": self._negative_low, "impedance_correction_real": self._k_re_array.tolist(), "impedance_correction_imag": self._k_im_array.tolist(), }
[docs] def parse(self, data: Dict[str, Union[str, float]], logger: Logger = Logger()) -> None: """ Parse the tap data :param data: dictionary representation of the tap :param logger: logger instance """ self.asymmetry_angle = data.get("asymmetry_angle", 90.0) self.total_positions = data.get("total_positions", 5) self.dV = data.get("dV", 0.01) self.neutral_position = data.get("neutral_position", 2) self.normal_position = data.get("normal_position", 2) self.tap_position = data.get("tap_position", 2) self.tc_type = TapChangerTypes(data.get("type", TapChangerTypes.NoRegulation.value)) low_step = data.get("low_step", None) if low_step is None: negative_low = data.get("negative_low", False) self._low_step = 1 - self.neutral_position if negative_low else 0 else: self._low_step = int(low_step) self._negative_low = self._low_step < 0 # parse the impedance correction factors _k_re_array = data.get("impedance_correction_real", None) if _k_re_array is not None: if len(_k_re_array) == self.total_positions: self._k_re_array = np.array(_k_re_array) else: self._k_re_array = np.ones(self._total_positions) logger.add_warning("Incorrect impedance table length") _k_im_array = data.get("impedance_correction_imag", None) if _k_im_array is not None: if len(_k_im_array) == self.total_positions: self._k_im_array = np.array(_k_im_array) else: self._k_im_array = np.ones(self._total_positions) logger.add_warning("Incorrect impedance table length") self.recalc()
[docs] def to_df(self) -> pd.DataFrame: """ Get DaraFrame of the values :return: DataFrame """ return pd.DataFrame(data={ 'Steps': self._ndv, 'tau': self._tau_array, 'm': self._m_array, 'impedance_correction_real': self._k_re_array, 'impedance_correction_imag': self._k_im_array, })
[docs] def reset(self) -> None: """ Resets the tap changer to the neutral position """ self.tap_position = self.neutral_position
[docs] def tap_up(self) -> None: """ Go to the next upper tap position """ if self.tap_position + 1 < len(self._ndv): self.tap_position += 1
[docs] def tap_down(self) -> None: """ Go to the next upper tap position """ if self.tap_position - 1 > 0: self.tap_position -= 1
[docs] def get_tap_phase2(self, tap_position: Union[int, np.ndarray]) -> Union[float, np.ndarray]: """ Get the tap phase in radians :return: phase in radians (single value or array) """ if self.tc_type == TapChangerTypes.NoRegulation: if isinstance(tap_position, int): return 0.0 elif isinstance(tap_position, np.ndarray): return np.zeros(len(tap_position)) else: raise ValueError("tap position must be int or np.ndarray of int type") elif self.tc_type == TapChangerTypes.VoltageRegulation: if isinstance(tap_position, int): return 0.0 elif isinstance(tap_position, np.ndarray): return np.zeros(len(tap_position)) else: raise ValueError("tap position must be int or np.ndarray of int type") elif self.tc_type == TapChangerTypes.Asymmetrical: ndu = self._ndv[tap_position] theta = np.deg2rad(self.asymmetry_angle) a = ndu * np.sin(theta) b = ndu * np.cos(theta) alpha = np.arctan(a / (1.0 + b)) return alpha elif self.tc_type == TapChangerTypes.Symmetrical: ndu = self._ndv[tap_position] alpha = 2.0 * np.arctan(ndu / 2.0) return alpha else: raise Exception("Unknown tap phase type")
[docs] def get_tap_module2(self, tap_position: Union[int, np.ndarray]) -> Union[float, np.ndarray]: """ Get the tap voltage regulation module :return: voltage regulation module (single value or array) """ if self.tc_type == TapChangerTypes.NoRegulation: if isinstance(tap_position, int): return 1.0 elif isinstance(tap_position, np.ndarray): return np.ones(len(tap_position)) else: raise ValueError("tap position must be int or np.ndarray of int type") elif self.tc_type == TapChangerTypes.VoltageRegulation: ndu = self._ndv[tap_position] return 1.0 / (1.0 - ndu + 1e-20) elif self.tc_type == TapChangerTypes.Asymmetrical: ndu = self._ndv[tap_position] theta = np.deg2rad(self.asymmetry_angle) a = ndu * np.sin(theta) b = ndu * np.cos(theta) rho = 1.0 / np.sqrt(np.power(a, 2) + np.power(1.0 + b, 2)) return rho elif self.tc_type == TapChangerTypes.Symmetrical: if isinstance(tap_position, int): return 1.0 elif isinstance(tap_position, np.ndarray): return np.ones(len(tap_position)) else: raise ValueError("tap position must be int or np.ndarray of int type") else: raise Exception("Unknown tap phase type")
[docs] def get_tap_phase(self) -> float: """ Get the tap phase in radians :return: phase in radians """ if self.tap_position < len(self._tau_array): return float(self._tau_array[self.tap_position]) else: print("tap position out of range") return 0.0
[docs] def get_tap_module(self) -> float: """ Get the tap voltage regulation module :return: voltage regulation module """ if self.tap_position < len(self._m_array): return float(self._m_array[self.tap_position]) else: print("tap position out of range") return 1.0
[docs] def set_tap_module(self, tap_module: float) -> float: """ Set the tap position closest to the tap module :param tap_module: float value of the tap module """ if self.tc_type != TapChangerTypes.NoRegulation: pos, val = find_closest_number(arr=self._m_array, target=tap_module) self.tap_position = pos return val else: return 1.0
[docs] def set_tap_phase(self, tap_phase: float) -> float: """ Set the tap position closest to the tap phase :param tap_phase: float value of the tap phase """ if self.tc_type != TapChangerTypes.NoRegulation: pos, val = find_closest_number(arr=self._tau_array, target=tap_phase) self.tap_position = pos return val else: return 0.0
[docs] def get_tap_module_min(self) -> float: """ Min tap module, computed on the fly :return: float """ return self.get_tap_module2(tap_position=0)
[docs] def get_tap_module_max(self) -> float: """ Max tap module, computed on the fly :return: float """ return self.get_tap_module2(tap_position=self.total_positions - 1)
[docs] def get_tap_phase_min(self) -> float: """ Min tap phase, computed on the fly :return: float """ return self.get_tap_phase2(tap_position=0)
[docs] def get_tap_phase_max(self) -> float: """ Maximum tap phase (calculated) :return: float """ return self.get_tap_phase2(tap_position=self.total_positions - 1)
def __eq__(self, other: "TapChanger") -> bool: """ Equality check :param other: TapChanger :return: ok? """ return ((self.asymmetry_angle == other.asymmetry_angle) and (self.total_positions == other.total_positions) and np.allclose(self.dV, other.dV, atol=1e-06) and (self.neutral_position == other.neutral_position) and (self.normal_position == other.normal_position) and (self.tap_position == other.tap_position) and (self.tc_type == other.tc_type)) def __str__(self) -> str: """ String representation :return: """ return "Tap changer"
[docs] def init_from_cgmes(self, low: int, high: int, normal: int, neutral: int, stepVoltageIncrement: float, step: int, asymmetry_angle: float = 0.0, tc_type: TapChangerTypes = TapChangerTypes.NoRegulation) -> None: """ Import TapChanger object from CGMES :param low: :param high: :param normal: :param neutral: :param stepVoltageIncrement: :param step: :param asymmetry_angle: :param tc_type: :return: """ self._low_step = int(low) self._negative_low = self._low_step < 0 self.asymmetry_angle = float(asymmetry_angle) # asymmetry angle (Theta) self._total_positions = int(high - low + 1) # total number of positions self.dV = float(stepVoltageIncrement / 100) # voltage increment in p.u. self.neutral_position = int(neutral - low) # zero-based neutral position self.normal_position = int(normal - low) # zero-based normal position self._tap_position = int(step - low) # zero-based tap position self.tc_type = tc_type # tap changer mode # Calculated arrays self._ndv = np.zeros(self._total_positions) self._tau_array = np.zeros(self._total_positions) self._m_array = np.zeros(self._total_positions) self._k_re_array = np.ones(self._total_positions) # impedance correction positions (real) self._k_im_array = np.ones(self._total_positions) # impedance correction positions (imag) self.recalc()
[docs] def get_cgmes_values(self): """ Returns with values of a Tap Changer in CGMES :return: :rtype: """ low = self._low_step high = low + self.total_positions - 1 normal = self.normal_position + low neutral = self.neutral_position + low sVI = round(self.dV * 100, 6) step = self.tap_position + low return low, high, normal, neutral, sVI, step