Breakthrough-Energy / PreREISE

Generate input data for scenario framework
https://breakthrough-energy.github.io/docs/
MIT License
20 stars 28 forks source link

feat: add loadability calculations and standard conductor parameters #272

Closed danielolsen closed 2 years ago

danielolsen commented 2 years ago

Pull Request doc

Purpose

What the code is doing

Testing

One new unit test is added, and existing unit tests are enhanced.

Usage Example/Visuals

Besides being able to instantiate a Conductor by name (e.g. c = Conductor("Ostrich")), there are no other changes to how the objects are used. The only difference is that additional attributes are available.

Time estimate

30 minutes? There calculations that the code is doing aren't very complex, but probably only make sense to the folks with a power systems background.

danielolsen commented 2 years ago

Here's a usage example, showing how we can leverage the new functionality to automatically generate the parameters needed for Grid-building:

Assume we have a CSV data table of representative line designs:

kV bundle_count conductor spacing a_x a_y b_x b_y c_x c_y
69 1 Osprey N/A -2 16.5 2 15 -2 13.5
115 1 Drake N/A -2 19 2 17 -2 15
138 1 Ortolan N/A -3 22.5 3 20 -3 17.5
230 1 Falcon N/A -7 20 0 20 7 20
230 2 Drake 0.5 -7 20 0 20 7 20
230 3 Drake 0.5 -7 20 0 20 7 20
345 1 Kiwi N/A -8 25 0 25 8 25
345 2 Falcon 0.5 -8 25 0 25 8 25
500 3 Rail 0.5 -10 30 0 30 10 30
765 4 Martin 0.5 15 40 0 40 15 40

The first three represent single-pole designs (phases oriented primarily vertically, two on one side and one on the other), while the other represent H-frame or lattice designs (phases oriented horizontally). All distances are measured in meters, with the center of the tower at ground level representing the origin.

We can build a function that interprets each row of this table into a Tower object:

def build_tower(series):
    """Build a Tower object using the transmission line design"""
    conductor = Conductor(series["conductor"])
    if series["bundle_count"] != 1:
        bundle = ConductorBundle(
            conductor=conductor,
            n=series["bundle_count"],
            spacing=series["spacing"],
        )
    else:
        bundle = ConductorBundle(conductor=conductor)  # n = 1 by default
    locations = PhaseLocations(
        a=tuple(series[["a_x", "a_y"]]),
        b=tuple(series[["b_x", "b_y"]]),
        c=tuple(series[["c_x", "c_y"]]),
    )
    tower = Tower(bundle=bundle, locations=locations)
    return tower

Then, we can define a function which calculates per-mile parameter values:

def calculate_per_mile_parameters(series):
    z_base = series["voltage"]**2 / 100  # 100 MVA base

    tower = build_tower(series)
    line = Line(tower=tower, voltage=series["voltage"], length=1.609)  # km in 1 mile
    output = pd.Series(
        {
            "reactance_per_mile": line.series_impedance.imag / z_base,
            "thermal_rating": line.thermal_rating,
            "surge_impedance_loading": line.surge_impedance_loading,
        }
    )
    return output

and one that calculates the rating for a 100-mile long line:

def calculate_100_mile_rating(series):
    tower = build_tower(series)
    line = Line(tower=tower, voltage=series["voltage"], length=160.9)  # km in 100 mile
    return line.power_rating

Putting these into action:

>>> import pandas as pd
>>> from prereise.gather.griddata.transmission.geometry import (
...     Conductor,
...     ConductorBundle,
...     Line,
...     PhaseLocations,
...     Tower,
... )
>>> data = pd.read_csv("path/to/line_designs.csv")
>>> one_mile_parameters = data.apply(calculate_per_mile_parameters, axis=1)
>>> # Add a meaningful index to the new calculations, using some of the original columns
>>> index_for_display = data.set_index(["voltage", "bundle_count"]).index
>>> one_mile_parameters.index = index_for_display
>>> one_mile_parameters
                      reactance_per_mile  thermal_rating  surge_impedance_loading
voltage bundle_count
69      1                       0.015501       85.450727                13.113853
115     1                       0.005447      183.250975                37.534569
138     1                       0.003941      250.974162                52.098497
230     1                       0.001448      551.744785               142.092507
        2                       0.001092      733.003902               187.338069
        3                       0.000947     1099.505853               215.762241
345     1                       0.000649      960.274948               318.173207
        2                       0.000482     1655.234354               425.428999
500     3                       0.000217     2585.085830               941.144695
765     4                       0.000092     6651.594716              2225.035726
>>> rating_100_mile = data.apply(calculate_100_mile_rating, axis=1)
>>> rating_100_mile.index = index_for_display
>>> rating_100_mile
voltage  bundle_count
69       1                 26.199249
115      1                 74.987693
138      1                104.083946
230      1                283.876688
         2                374.269634
         3                431.056300
345      1                635.656013
         2                849.934862
500      3               1880.247205
765      4               4445.243361
dtype: float64

As would be expected, the rating for a line of about 100 miles is less than the thermal rating, and about 2x the surge impedance loading, matching the St. Clair curve.

To build a full Grid, we just need to associate each branch with a representative tower design, and then we can use that line's specific length to calculate the allowable power transfer, which will always be the lower of the thermal limit or the stability limit. Extending the tower-building function to multiple circuits on a tower is fairly trivial: we can follow the same procedure as with single vs. multiple conductors, where one column tells us which other columns we should be looking at to build the appropriate Tower object.

rouille commented 2 years ago

Reading the code, it looks like a conductor bundle is made of the same type of conductor. Is that what happens in reality or a this is use to simplify the model?

danielolsen commented 2 years ago

Reading the code, it looks like a conductor bundle is made of the same type of conductor. Is that what happens in reality or a this is use to simplify the model?

I believe this is true to real life: I've never seen reference to a heterogeneous conductor bundle.

There are a few other implicit assumptions that I think are more often violated in real life, to varying degrees:

I think these assumptions are all reasonable though, since to account for them would require more complicated logic and/or data that is unavailable or very difficult to collect, and would probably not make a large impact on the overall calculation results.

rouille commented 2 years ago

In the geometry module, you import cmath and constants/functions from math. Can we just use cmath for everything, it seems to handle complex numbers along with float/integers. It brings me to my second question, where do we encounter complex numbers? Are we dealing with complex impedance?

danielolsen commented 2 years ago

In the geometry module, you import cmath and constants/functions from math. Can we just use cmath for everything, it seems to handle complex numbers along with float/integers. It brings me to my second question, where do we encounter complex numbers? Are we dealing with complex impedance?

We are indeed working with complex impedance. Properly calculating the impedance of long-distance transmission lines requires doing calculations which combine series resistance (real), series reactance (imaginary), and shunt admittance (imaginary).

cmath functions will always return a complex result, which is why I've stuck with math.sqrt over cmath.sqrt when the arguments are purely real.

rouille commented 2 years ago

In the geometry module, you import cmath and constants/functions from math. Can we just use cmath for everything, it seems to handle complex numbers along with float/integers. It brings me to my second question, where do we encounter complex numbers? Are we dealing with complex impedance?

We are indeed working with complex impedance. Properly calculating the impedance of long-distance transmission lines requires doing calculations which combine series resistance (real), series reactance (imaginary), and shunt admittance (imaginary).

cmath functions will always return a complex result, which is why I've stuck with math.sqrt over cmath.sqrt when the arguments are purely real.

Thanks for the explanation. The other option is to take the real part of the output:

>>> import cmath
>>> cmath.sqrt(1)
(1+0j)
>>> cmath.sqrt(1).real
1.0

Not very important