Source code for prereise.gather.griddata.transmission.geometry

import cmath
from dataclasses import dataclass, field
from itertools import combinations
from math import exp, log, pi, sqrt
from statistics import geometric_mean

from prereise.gather.griddata.transmission.const import (
    epsilon_0,
    mu_0,
    relative_permeability,
    resistivity,
)
from prereise.gather.griddata.transmission.helpers import (
    DataclassWithValidation,
    approximate_loadability,
    get_standard_conductors,
)


[docs]@dataclass class Conductor(DataclassWithValidation): """Represent a single conductor (which may be a stranded composite). Conductors can be instantiated by either: - looking them up via their standardized bird ``name``, - passing the parameters relevant to impedance and rating calculations (``radius``, ``gmr``, ``resistance_per_km``, and ``current_limit``), or - passing paramters which can be used to estimate parameters relevant to impedance and rating calculations (``radius``, ``material``, and ``current_limit``). In this case, a solid conductor is assumed. :param str name: name of standard conductor. :param float radius: outer radius of conductor. :param str material: material of conductor. Used to calculate ``resistance_per_km`` and ``gmr`` if these aren't passed to the constructor, unnecessary otherwise. :param float resistance_per_km: resistance (ohms) per kilometer. Will be estimated from other parameters if it isn't passed. :param float gmr: geometric mean radius of conductor. Will be estimated from other parameters if it isn't passed. :param float area: cross-sectional area of conductor. Will be estimated from other parameters if it isn't passed. """ name: str = None radius: float = None material: str = None resistance_per_km: float = None gmr: float = None area: float = None permeability: float = None current_limit: float = None def __post_init__(self): physical_parameters = { self.radius, self.material, self.resistance_per_km, self.gmr, self.area, self.permeability, self.current_limit, } # Validate inputs self.validate_input_types() # defined in DataclassWithValidation if self.name is not None: if any([a is not None for a in physical_parameters]): raise TypeError("If name is specified, no other parameters can be") self._get_parameters_from_standard_conductor_table() else: self._infer_missing_parameters() def _infer_missing_parameters(self): if self.gmr is None and (self.material is None): raise ValueError( "If gmr is not provided, material and radius are needed to estimate" ) if self.resistance_per_km is None and self.material is None: raise ValueError( "If resistance_per_km is not provided, material is needed to estimate" ) # Estimate missing inputs using the inputs which are present if self.gmr is None: try: self.permeability = relative_permeability[self.material] except KeyError: raise ValueError( f"Unknown permeability for {self.material}, can't calculate gmr" ) self.gmr = self.radius * exp(self.permeability / 4) if self.resistance_per_km is None: try: self.resistivity = resistivity[self.material] except KeyError: raise ValueError( f"Unknown resistivity for {self.material}, " "can't calculate resistance" ) if self.area is None: self.area = pi * self.radius**2 # convert per-m to per-km self.resistance_per_km = self.resistivity * 1000 / self.area def _get_parameters_from_standard_conductor_table(self): standard_conductors = get_standard_conductors() title_cased_name = self.name.title() if title_cased_name not in standard_conductors.index: raise ValueError(f"No conductor named '{self.name}' in standard table") data = standard_conductors.loc[title_cased_name] self.gmr = data["gmr_mm"] / 1e3 self.radius = data["radius_mm"] / 1e3 self.resistance_per_km = data["resistance_ac_per_km_75c"] self.current_limit = data["max_amps"] self.name = title_cased_name
[docs]@dataclass class ConductorBundle(DataclassWithValidation): """Represent a bundle of conductors (or a 'bundle' of one). :param int n: number of conductors in bundle (can be one). :param Conductor conductor: information for each conductor. :param float spacing: distance between the centers of each conductor (meters). :param str layout: either 'circular' (conductors are arranged in a regular polygon with edge length ``spacing``) or 'flat' (conductors are arranged in a line, at regular spacing ``spacing``). """ conductor: Conductor n: int = 1 spacing: float = None # we need to be able to ignore spacing for a single conductor layout: str = "circular" resistance_per_km: float = field(init=False) spacing_L: float = field(init=False) # noqa: N815 spacing_C: float = field(init=False) # noqa: N815 current_limit: float = field(init=False, default=None) def __post_init__(self): self.validate_input_types() # defined in DataclassWithValidation if self.n != 1 and self.spacing is None: raise ValueError("With multiple conductors, spacing must be specified") self.resistance_per_km = self.conductor.resistance_per_km / self.n self.spacing_L = self.calculate_equivalent_spacing("inductance") self.spacing_C = self.calculate_equivalent_spacing("capacitance") if self.conductor.current_limit is not None: self.current_limit = self.conductor.current_limit * self.n
[docs] def calculate_equivalent_spacing(self, type="inductance"): if type == "inductance": conductor_distance = self.conductor.gmr elif type == "capacitance": conductor_distance = self.conductor.radius else: raise ValueError("type must be either 'inductance' or 'capacitance'") if self.n == 1: return conductor_distance elif self.n == 2: return (conductor_distance * self.spacing) ** (1 / 2) else: if self.layout == "circular": return self.calculate_equivalent_spacing_circular(conductor_distance) if self.layout == "flat": return self.calculate_equivalent_spacing_flat(conductor_distance) raise ValueError(f"Unknown layout: {self.layout}")
[docs] def calculate_equivalent_spacing_circular(self, conductor_distance): if self.n == 3: return (conductor_distance * self.spacing**2) ** (1 / 3) if self.n == 4: return (conductor_distance * self.spacing**3 * 2 ** (1 / 2)) ** (1 / 4) raise NotImplementedError( "Geometry calculations are only implemented for 1 <= n <= 4" )
[docs] def calculate_equivalent_spacing_flat(self, conductor_distance): if self.n == 3: return (conductor_distance * 2 * self.spacing**2) ** (1 / 3) if self.n == 4: return (conductor_distance * 12 * self.spacing**3) ** (1 / 8) raise NotImplementedError( "Geometry calculations are only implemented for 1 <= n <= 4" )
[docs]@dataclass class PhaseLocations(DataclassWithValidation): """Represent the locations of each conductor bundle on a transmission tower. Each of ``a``, ``b``, and ``c`` are the (x, y) location(s) of that phase's conductor(s). :param tuple a: the (x, y) location of the single 'A' phase conductor if ``circuits`` == 1, or the ((x1, y1), (x2, y2), ...) locations of the 'A' phase conductors if ``circuits`` > 1. Units are meters. :param tuple b: the (x, y) location of the single 'B' phase conductor if ``circuits`` == 1, or the ((x1, y1), (x2, y2), ...) locations of the 'B' phase conductors if ``circuits`` > 1. Units are meters. :param tuple c: the (x, y) location of the single 'C' phase conductor if ``circuits`` == 1, or the ((x1, y1), (x2, y2), ...) locations of the 'C' phase conductors if ``circuits`` > 1. Units are meters. :param int circuits: the number of circuits on the tower. """ a: tuple b: tuple c: tuple circuits: int = 1 equivalent_distance: float = field(init=False) equivalent_height: float = field(init=False) phase_self_distances: dict = field(init=False, default=None) equivalent_reflected_distance: float = field(init=False) def __post_init__(self): self.validate_input_types() # defined in DataclassWithValidation if not (len(self.a) == len(self.b) == len(self.c)): raise ValueError("each phase location must have the same length") if self.circuits == 1 and len(self.a) == 2: # Single-circuit specified as (x, y) will be converted to ((x, y)) self.a = (self.a,) self.b = (self.b,) self.c = (self.c,) self.calculate_distances()
[docs] def calculate_distances(self): self.true_distance = { "ab": _geometric_mean_euclidian(self.a, self.b), "ac": _geometric_mean_euclidian(self.a, self.c), "bc": _geometric_mean_euclidian(self.b, self.c), } # 'Equivalent' distances are geometric means self.equivalent_distance = geometric_mean(self.true_distance.values()) self.equivalent_height = geometric_mean( [self.a[0][1], self.b[0][1], self.c[0][1]] ) if self.circuits == 1: self.calculate_single_circuit_distances() else: self.calculate_multi_circuit_distances()
[docs] def calculate_single_circuit_distances(self): # The distance bounced off the ground, or 'reflected', is used for # single-circuit capacitance calculations self.reflected_distance = { "ab": _euclidian(self.a[0], (self.b[0][0], -self.b[0][1])), # a -> b' "ac": _euclidian(self.a[0], (self.c[0][0], -self.c[0][1])), # a -> c' "bc": _euclidian(self.b[0], (self.c[0][0], -self.c[0][1])), # b -> c' } self.equivalent_reflected_distance = geometric_mean( self.reflected_distance.values() )
[docs] def calculate_multi_circuit_distances(self): self.phase_self_distances = [ geometric_mean(_euclidian(p0, p1) for p0, p1 in combinations(phase, 2)) for phase in (self.a, self.b, self.c) ] # Multi circuit, so we assume tall tower negligible impact from reflectance self.equivalent_reflected_distance = 2 * self.equivalent_height
[docs]@dataclass class Tower(DataclassWithValidation): """Given the geometry of a transmission tower and conductor bundle information, estimate per-kilometer inductance, resistance, and shunt capacitance. :param PhaseLocations locations: the locations of each conductor bundle. :param ConductorBundle bundle: the parameters of each conductor bundle. """ locations: PhaseLocations bundle: ConductorBundle resistance: float = field(init=False) inductance: float = field(init=False) capacitance: float = field(init=False) phase_current_limit: float = field(init=False, default=None) def __post_init__(self): self.validate_input_types() # defined in DataclassWithValidation self.resistance = self.bundle.resistance_per_km / self.locations.circuits self.inductance = self.calculate_inductance_per_km() self.capacitance = self.calculate_shunt_capacitance_per_km() if self.bundle.current_limit is not None: self.phase_current_limit = ( self.bundle.current_limit * self.locations.circuits )
[docs] def calculate_inductance_per_km(self): denominator = _circuit_bundle_distances( self.bundle.spacing_L, self.locations.phase_self_distances ) inductance_per_km = ( mu_0 / (2 * pi) * log(self.locations.equivalent_distance / denominator) ) return inductance_per_km
[docs] def calculate_shunt_capacitance_per_km(self): denominator = _circuit_bundle_distances( self.bundle.spacing_C, self.locations.phase_self_distances ) capacitance_per_km = (2 * pi * epsilon_0) / ( log(self.locations.equivalent_distance / denominator) - log( self.locations.equivalent_reflected_distance / (2 * self.locations.equivalent_height) ) ) return capacitance_per_km
[docs]@dataclass class Line(DataclassWithValidation): """Given a Tower design, line voltage, and length, calculate whole-line impedances and rating. :param Tower tower: tower parameters (containing per-kilometer impedances). :param int/float length: line length (kilometers). :param int/float voltage: line voltage (kilovolts). :param int/float freq: the system nominal frequency (Hz). """ tower: Tower length: float voltage: float freq: float = 60.0 series_impedance_per_km: complex = field(init=False) shunt_admittance_per_km: complex = field(init=False) propogation_constant_per_km: complex = field(init=False) surge_impedance: complex = field(init=False) series_impedance: complex = field(init=False) shunt_admittance: complex = field(init=False) thermal_rating: float = field(init=False, default=None) stability_rating: float = field(init=False, default=None) power_rating: float = field(init=False, default=None) def __post_init__(self): # Convert integers to floats as necessary for attr in ("freq", "length", "voltage"): if isinstance(getattr(self, attr), int): setattr(self, attr, float(getattr(self, attr))) self.validate_input_types() # defined in DataclassWithValidation # Calculate second-order electrical parameters which depend on frequency omega = 2 * pi * self.freq self.series_impedance_per_km = ( self.tower.resistance + 1j * self.tower.inductance * omega ) self.shunt_admittance_per_km = 1j * self.tower.capacitance * omega self.surge_impedance = cmath.sqrt( self.series_impedance_per_km / self.shunt_admittance_per_km ) self.propogation_constant_per_km = cmath.sqrt( self.series_impedance_per_km * self.shunt_admittance_per_km ) self.surge_impedance_loading = ( self.tower.locations.circuits * self.voltage**2 / abs(self.surge_impedance) ) # Calculate loadability (depends on length) if self.tower.phase_current_limit is not None: self.thermal_rating = ( self.voltage * self.tower.phase_current_limit * sqrt(3) / 1e3 # MW ) self.stability_rating = ( self.tower.locations.circuits * approximate_loadability(self.length) * self.surge_impedance_loading ) self.power_rating = min(self.thermal_rating, self.stability_rating) # Use the long-line transmission model to calculate lumped-element parameters self.series_impedance = (self.series_impedance_per_km * self.length) * ( cmath.sinh(self.propogation_constant_per_km * self.length) / (self.propogation_constant_per_km * self.length) ) self.shunt_admittance = (self.shunt_admittance_per_km * self.length) * ( cmath.tanh(self.propogation_constant_per_km * self.length / 2) / (self.propogation_constant_per_km * self.length / 2) )
def _euclidian(a, b): """Calculate the euclidian distance between two points.""" try: if len(a) != len(b): raise ValueError("Length of a and b must be equivalent") except TypeError: raise TypeError( "a and b must both be iterables compatible with the len() function" ) return sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) def _geometric_mean_euclidian(a_list, b_list): """Calculate the geometric mean euclidian distance between two coordinate lists.""" try: if len(a_list) != len(b_list): raise ValueError("Length of a_list and b_list must be equivalent") except TypeError: raise TypeError("a_list and b_list must both be iterables") return geometric_mean(_euclidian(a, b) for a in a_list for b in b_list) def _circuit_bundle_distances(bundle_distance, phase_distances=None): """Calculate characteristic distance of bundle and circuit distances.""" if phase_distances is None: return bundle_distance phase_characteristic_distances = [ sqrt(phase * bundle_distance) for phase in phase_distances ] return geometric_mean(phase_characteristic_distances)