diff --git a/README.rst b/README.rst index 8cc679e..332aa93 100644 --- a/README.rst +++ b/README.rst @@ -64,7 +64,7 @@ of the conductor for that phase. .. code:: python - from carsons import CarsonsEquations, perform_kron_reduction + from carsons import CarsonsEquations, perform_kron_reduction, impedance class Line: gmr: { @@ -72,11 +72,11 @@ of the conductor for that phase. ... } r: { - 'A' => per-length resistance of conductor A in ohms + 'A': per-length resistance of conductor A in ohms ... } phase_positions: { - 'A' => (x, y) cross-sectional position of the conductor in meters + 'A': (x, y) cross-sectional position of the conductor in meters ... } phases: {'A', ... } @@ -86,6 +86,7 @@ of the conductor for that phase. z_primitive = CarsonsEquations(Line()).build_z_primitive() z_abc = perform_kron_reduction(z_primitive) + line_impedance = impedance(CarsonsEquations(Line())) The model supports any combination of ABC phasings (for example BC, BCN etc...) @@ -101,6 +102,63 @@ For examples of how to use the model, see the `tests `_. + +### Concentric Neutral Cable + +``carsons`` also supports modelling of concentric neutral cables of any phasings. +Its usage is very similar to the example above, only requires a few more +parameters about the neutral conductors in the line model object. + +.. code:: python + + + from carsons import (ConcentricNeutralCarsonsEquations, + perform_kron_reduction, + impedance) + + class Line: + resistance: { + 'A': per-length resistance of conductor A in ohms + ... + } + geometric_mean_radius: { + 'A': geometric_mean_radius_A + ... + } + phase_positions: { + 'A' => (x, y) cross-sectional position of the conductor in meters + ... + } + phases: {'A', 'NA', ... } + neutral_strand_gmr: { + 'NA': neutral_strand_gmr_A + ... + } + neutral_strand_resistance: { + 'NA': neutral_strand_resistance_A + ... + } + neutral_strand_diameter: { + 'NA': neutral_strand_diameter_A + ... + } + diameter_over_neutral: { + 'NA': diameter_over_neutral_A + ... + } + neutral_strand_count: { + 'NA': neutral_strand_count_A + ... + } + + + z_primitive = ConcentricNeutralCarsonsEquations(Line()).build_z_primitive() + z_abc = perform_kron_reduction(z_primitive) + line_impedance = impedance(ConcentricNeutralCarsonsEquations(Line())) + +For examples of how to use the model, see the `tests `_. + + Problem Description ------------------- diff --git a/carsons/__init__.py b/carsons/__init__.py index 8c46b7d..6945286 100644 --- a/carsons/__init__.py +++ b/carsons/__init__.py @@ -1,3 +1,5 @@ -from carsons.carsons import convert_geometric_model # noqa 401 +from carsons.carsons import (convert_geometric_model, # noqa 401 + impedance, # noqa 401 + ConcentricNeutralCarsonsEquations) # noqa 401 name = "carsons" diff --git a/carsons/carsons.py b/carsons/carsons.py index 771b4f7..0f98fab 100755 --- a/carsons/carsons.py +++ b/carsons/carsons.py @@ -1,13 +1,9 @@ -from numpy import pi as π +from collections import defaultdict +from itertools import islice -from numpy import zeros +from numpy import arctan, cos, log, sin, sqrt, zeros +from numpy import pi as π from numpy.linalg import inv -from numpy import sqrt -from numpy import log -from numpy import cos -from numpy import sin -from numpy import arctan -from itertools import islice def convert_geometric_model(geometric_model): @@ -18,6 +14,12 @@ def convert_geometric_model(geometric_model): return z_abc +def impedance(model): + z_primitive = model.build_z_primitive() + z_abc = perform_kron_reduction(z_primitive) + return z_abc + + def perform_kron_reduction(z_primitive): """ Reduces the primitive impedance matrix to an equivalent impedance matrix. @@ -107,16 +109,17 @@ def compute_X(self, i, j): Qᵢⱼ = self.compute_Q(i, j) ΔX = self.μ * self.ω / π * Qᵢⱼ + # calculate geometry ratio 𝛥G if i != j: Dᵢⱼ = self.compute_D(i, j) dᵢⱼ = self.compute_d(i, j) - geometry_ratio = Dᵢⱼ / dᵢⱼ + 𝛥G = Dᵢⱼ / dᵢⱼ else: hᵢ = self.get_h(i) gmrⱼ = self.gmr[j] - geometry_ratio = 2.0 * hᵢ / gmrⱼ + 𝛥G = 2.0 * hᵢ / gmrⱼ - X_o = self.ω * self.μ / (2 * π) * log(geometry_ratio) + X_o = self.ω * self.μ / (2 * π) * log(𝛥G) return X_o + ΔX @@ -172,6 +175,7 @@ def compute_d(self, i, j): def compute_D(self, i, j): xⱼ, yⱼ = self.phase_positions[j] + return self.calculate_distance(self.phase_positions[i], (xⱼ, -yⱼ)) @staticmethod @@ -183,3 +187,74 @@ def calculate_distance(positionᵢ, positionⱼ): def get_h(self, i): _, yᵢ = self.phase_positions[i] return yᵢ + + +class ConcentricNeutralCarsonsEquations(CarsonsEquations): + def __init__(self, model, *args, **kwargs): + super().__init__(model) + self.neutral_strand_gmr = model.neutral_strand_gmr + self.neutral_strand_count = defaultdict( + lambda: None, model.neutral_strand_count) + self.neutral_strand_resistance = model.neutral_strand_resistance + self.radius = defaultdict(lambda: None, { + phase: (diameter_over_neutral - + model.neutral_strand_diameter[phase]) / 2 + for phase, diameter_over_neutral + in model.diameter_over_neutral.items() + }) + self.phase_positions.update({ + f"N{phase}": self.phase_positions[phase] + for phase in self.phase_positions.keys() + }) + self.gmr.update({ + phase: self.GMR_cn(phase) + for phase in model.diameter_over_neutral.keys() + }) + self.r.update({ + phase: resistance / model.neutral_strand_count[phase] + for phase, resistance in model.neutral_strand_resistance.items() + }) + return + + def compute_d(self, i, j): + I, J = set(i), set(j) + r = self.radius[i] or self.radius[j] + + one_neutral_same_phase = I ^ J == set('N') + different_phase = not I & J + one_neutral = 'N' in I ^ J + + if one_neutral_same_phase: + # Distance between a neutral/phase conductor of same phase + return r + + distance_ij = self.calculate_distance(self.phase_positions[i], + self.phase_positions[j]) + if different_phase and one_neutral: + # Distance between a neutral/phase conductor of different phase + # approximate by modelling the concentric neutral cables as one + # equivalent conductor directly above the phase conductor + return (distance_ij**2 + r**2) ** 0.5 + else: + # Distance between two neutral/phase conductors + return distance_ij + + def compute_X(self, i, j): + Q_first_term = super().compute_Q(i, j, 1) + + # Simplify equations and don't compute Dᵢⱼ explicitly + kᵢⱼ_Dᵢⱼ_ratio = sqrt(self.ω * self.μ / self.ρ) + ΔX = Q_first_term * 2 + log(2) + + if i == j: + X_o = -log(self.gmr[i]) - log(kᵢⱼ_Dᵢⱼ_ratio) + else: + X_o = -log(self.compute_d(i, j)) - log(kᵢⱼ_Dᵢⱼ_ratio) + + return (X_o + ΔX) * self.ω * self.μ / (2 * π) + + def GMR_cn(self, phase): + GMR_s = self.neutral_strand_gmr[phase] + k = self.neutral_strand_count[phase] + R = self.radius[phase] + return (GMR_s * k * R**(k-1))**(1/k) diff --git a/setup.py b/setup.py index 358fa2e..8ecb99d 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,6 @@ def readme(): 'numpy>=1.13.1', ], extras_require={ - "test": ["pytest>=3.6", "pytest-cov"], + "test": ["pytest>=3.6", "pytest-cov", "pint"], }, ) diff --git a/tests/helpers.py b/tests/helpers.py index 62a0bf8..5bed7ad 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -26,3 +26,65 @@ def wire_positions(self): @property def phases(self): return self._phases + + +class ConcentricLineModel: + def __init__(self, conductors): + self._resistance = {} + self._geometric_mean_radius = {} + self._wire_positions = {} + self._phases = {} + self._neutral_strand_gmr = {} + self._neutral_strand_resistance = {} + self._neutral_strand_diameter = {} + self._diameter_over_neutral = {} + self._neutral_strand_count = {} + + for phase, val in conductors.items(): + if 'N' in phase: + self._neutral_strand_gmr[phase] = val['neutral_strand_gmr'] + self._neutral_strand_resistance[phase] = val['neutral_strand_resistance'] # noqa 401 + self._neutral_strand_diameter[phase] = val['neutral_strand_diameter'] # noqa 401 + self._diameter_over_neutral[phase] = val['diameter_over_neutral'] # noqa 401 + self._neutral_strand_count[phase] = val['neutral_strand_count'] + else: + self._resistance[phase] = val['resistance'] + self._geometric_mean_radius[phase] = val['gmr'] + self._wire_positions[phase] = val['wire_positions'] + self._phases = sorted(list(conductors.keys())) + + @property + def resistance(self): + return self._resistance + + @property + def geometric_mean_radius(self): + return self._geometric_mean_radius + + @property + def wire_positions(self): + return self._wire_positions + + @property + def phases(self): + return self._phases + + @property + def neutral_strand_gmr(self): + return self._neutral_strand_gmr + + @property + def neutral_strand_resistance(self): + return self._neutral_strand_resistance + + @property + def neutral_strand_diameter(self): + return self._neutral_strand_diameter + + @property + def diameter_over_neutral(self): + return self._diameter_over_neutral + + @property + def neutral_strand_count(self): + return self._neutral_strand_count diff --git a/tests/test_concentric_neutral_cable.py b/tests/test_concentric_neutral_cable.py new file mode 100644 index 0000000..abfad1b --- /dev/null +++ b/tests/test_concentric_neutral_cable.py @@ -0,0 +1,217 @@ +import pint +from numpy import array +from numpy.testing import assert_array_almost_equal + +from carsons import ConcentricNeutralCarsonsEquations, impedance +from tests.helpers import ConcentricLineModel +from tests.test_carsons import OHM_PER_MILE_TO_OHM_PER_METER + +ureg = pint.UnitRegistry() + +feet = ureg.feet +inches = ureg.inches +miles = ureg.miles +ohms = ureg.ohms +kft = ureg.feet * 1000 + + +def test_concentric_neutral_cable(): + """ + Validation test against example in Kersting's book. + """ + model = ConcentricNeutralCarsonsEquations(ConcentricLineModel({ + "A": { + 'resistance': (0.4100*(ohms / miles)).to('ohm / meters').magnitude, + 'gmr': (0.0171*feet).to('meters').magnitude, + 'wire_positions': (0, 0) + }, + "B": { + 'resistance': (0.4100*(ohms / miles)).to('ohm / meters').magnitude, + 'gmr': (0.0171*feet).to('meters').magnitude, + 'wire_positions': ((6*inches).to('meters').magnitude, 0) + }, + "C": { + 'resistance': (0.4100*(ohms / miles)).to('ohm / meters').magnitude, + 'gmr': (0.0171*feet).to('meters').magnitude, + 'wire_positions': ((12*inches).to('meters').magnitude, 0) + }, + + "NA": { + 'neutral_strand_gmr': (0.00208*feet).to('meters').magnitude, + 'neutral_strand_resistance': + (14.87*ohms / miles).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.0641*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.29*inches).to('meters').magnitude, + 'neutral_strand_count': 13, + }, + "NB": { + 'neutral_strand_gmr': (0.00208*feet).to('meters').magnitude, + 'neutral_strand_resistance': + (14.87*ohms / miles).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.0641*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.29*inches).to('meters').magnitude, + 'neutral_strand_count': 13, + }, + "NC": { + 'neutral_strand_gmr': (0.00208*feet).to('meters').magnitude, + 'neutral_strand_resistance': + (14.87*ohms / miles).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.0641*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.29*inches).to('meters').magnitude, + 'neutral_strand_count': 13, + }, + })) + + assert_array_almost_equal( + impedance(model), + array([ + [0.7981 + 1j*0.4467, 0.3188 + 1j*0.0334, 0.2848 + 1j*0.0138], + [0.3188 + 1j*0.0334, 0.7890 + 1j*0.4048, 0.3188 + 1j*0.0334], + [0.2848 + 1j*0.0138, 0.3188 + 1j*0.0334, 0.7981 + 1j*0.4467], + + ]) * OHM_PER_MILE_TO_OHM_PER_METER, + decimal=4 + ) + + +def test_concentric_neutral_cable_IEEE37(): + """ + Validation test against IEEE37 network underground cable configuration 723. + """ + + model = ConcentricNeutralCarsonsEquations(ConcentricLineModel({ + "A": { + 'resistance': (0.7690 * (ohms/miles)).to('ohm / meters').magnitude, + 'gmr': (0.0125 * feet).to('meters').magnitude, + 'wire_positions': (0, 0) + }, + "B": { + 'resistance': (0.7690 * (ohms/miles)).to('ohm / meters').magnitude, + 'gmr': (0.0125 * feet).to('meters').magnitude, + 'wire_positions': ((6 * inches).to('meters').magnitude, 0) + }, + "C": { + 'resistance': (0.7690 * (ohms/miles)).to('ohm / meters').magnitude, + 'gmr': (0.0125 * feet).to('meters').magnitude, + 'wire_positions': ((12 * inches).to('meters').magnitude, 0) + }, + + "NA": { + 'neutral_strand_gmr': (0.00208 * feet).to('meters').magnitude, + 'neutral_strand_resistance': + (14.87 * ohms / miles).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.0641*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.10 * inches).to('meters').magnitude, + 'neutral_strand_count': 7, + }, + "NB": { + 'neutral_strand_gmr': (0.00208 * feet).to('meters').magnitude, + 'neutral_strand_resistance': + (14.87 * ohms / miles).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.0641*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.10 * inches).to('meters').magnitude, + 'neutral_strand_count': 7, + }, + "NC": { + 'neutral_strand_gmr': (0.00208 * feet).to('meters').magnitude, + 'neutral_strand_resistance': + (14.87 * ohms / miles).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.0641*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.10 * inches).to('meters').magnitude, + 'neutral_strand_count': 7, + }, + })) + + assert_array_almost_equal( + impedance(model), + array([ + [1.2936 + 1j*0.6713, 0.4871 + 1j*0.2111, 0.4585 + 1j*0.1521], + [0.4871 + 1j*0.2111, 1.3022 + 1j*0.6326, 0.4871 + 1j*0.2111], + [0.4585 + 1j*0.1521, 0.4871 + 1j*0.2111, 1.2936 + 1j*0.6713], + + ]) * OHM_PER_MILE_TO_OHM_PER_METER, + decimal=4 + ) + + +def test_2ph_concentric_neutral_cable(): + """ + Validation test against OpenDSS example found in documentation + http://svn.code.sf.net/p/electricdss/code/trunk/Distrib/Doc/ + 'TechNote CableModelling.pdf' - Practical Example: Concentric Neutral Cable + """ + + model = ConcentricNeutralCarsonsEquations(ConcentricLineModel({ + "A": { + 'resistance': (0.0776 * (ohms/kft)).to('ohm / meters').magnitude, + 'gmr': (0.205 * inches).to('meters').magnitude, + 'wire_positions': (0, 0) + }, + "B": { + 'resistance': (0.0776 * (ohms/kft)).to('ohm / meters').magnitude, + 'gmr': (0.205 * inches).to('meters').magnitude, + 'wire_positions': ((6 * inches).to('meters').magnitude, 0) + }, + "NA": { + 'neutral_strand_gmr': (0.02496 * inches).to('meters').magnitude, + 'neutral_strand_resistance': + (2.55 * (ohms/kft)).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.064*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.29 * inches).to('meters').magnitude, + 'neutral_strand_count': 13, + }, + "NB": { + 'neutral_strand_gmr': (0.02496 * inches).to('meters').magnitude, + 'neutral_strand_resistance': + (2.55 * (ohms/kft)).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.064*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.29 * inches).to('meters').magnitude, + 'neutral_strand_count': 13, + }, + })) + + assert_array_almost_equal( + impedance(model), + array([ + [0.867953 + 1j*0.442045, 0.389392 + 1j*0.0511399, 0 + 1j * 0], + [0.389392 + 1j*0.0511399, 0.867953 + 1j*0.442045, 0 + 1j * 0], + [0 + 1j * 0, 0 + 1j * 0, 0 + 1j * 0], + + ]) * OHM_PER_MILE_TO_OHM_PER_METER, + decimal=4 + ) + + +def test_1ph_concentric_neutral_cable(): + """ + Validation test against OpenDSS example found in documentation + http://svn.code.sf.net/p/electricdss/code/trunk/Distrib/Doc/ + 'TechNote CableModelling.pdf' - Practical Example: Concentric Neutral Cable + """ + + model = ConcentricNeutralCarsonsEquations(ConcentricLineModel({ + "A": { + 'resistance': (0.0776 * (ohms/kft)).to('ohm / meters').magnitude, + 'gmr': (0.205 * inches).to('meters').magnitude, + 'wire_positions': (0, 0) + }, + "NA": { + 'neutral_strand_gmr': (0.02496 * inches).to('meters').magnitude, + 'neutral_strand_resistance': + (2.55 * (ohms/kft)).to('ohm / meters').magnitude, + 'neutral_strand_diameter': (0.064*inches).to('meters').magnitude, + 'diameter_over_neutral': (1.29 * inches).to('meters').magnitude, + 'neutral_strand_count': 13, + }, + })) + + assert_array_almost_equal( + impedance(model), + array([ + [1.04185 + 1j*0.602329, 0 + 1j * 0, 0 + 1j * 0], + [0 + 1j * 0, 0 + 1j * 0, 0 + 1j * 0], + [0 + 1j * 0, 0 + 1j * 0, 0 + 1j * 0], + + ]) * OHM_PER_MILE_TO_OHM_PER_METER, + decimal=4 + )