Supercritical CO2 recompression cycle

This is a model of a supercritical CO2 Brayton cycle with recompression and two recuperators providing 100 MW net power. The model is based on the model used validate the exergy analysis originally published in [9]. It is validated against the data of Penkuhn and Tsatsaronis [10]. The original model is available in the sCO2_exergy repository.

../_images/flowsheet4.svg ../_images/flowsheet_darkmode4.svg

On top of the model validating the data reported, the ModelTemplate is used to implement

  • a log(p)-h diagram of the process,

  • run sensitivity analyses on some parameters, and

  • apply a multi-objective optimization of efficiency and recuperator UA

Imports

Next to the tespy imports we need the FluidPropertyDiagram from fluprodia and the NSGA2 algorithm for multi-objective optimization from pymoo.

import os
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from fluprodia import FluidPropertyDiagram
from pymoo.algorithms.moo.nsga2 import NSGA2

from tespy.components import (
    Compressor, CycleCloser, Generator, HeatSource, Merge, Motor,
    PowerBus, PowerSink, SectionedHeatExchanger, SimpleHeatExchanger,
    Splitter, Turbine
)
from tespy.connections import Connection, HeatConnection, PowerConnection, Ref
from tespy.models.template import ModelTemplate
from tespy.networks import Network

The SCO2Model class

The model class inherits from ModelTemplate, which implements input and output parameter handling, solving, plotting diagrams and running sensitivity analyses and optimization.

We need to implement these three methods:

  • _create_network to build the topology and apply the initial boundary conditions specifications.

  • _parameter_lookup to map names onto parameters in the network.

  • solve_model to let the optimizer and sensitivity analysis know, how to solve a model.

create_diagram is overridden because the base class isolines do not go beyond 200 °C while this cycle reaches 600 °C at 250 bar.

class SCO2Model(ModelTemplate):
    """Supercritical CO2 power cycle with recompression and recuperators."""

    # specification of ambient state
    pamb = 1.01325  # bar
    Tamb = 15  # °C

    def _create_network(self):
        self.nw = Network(iterinfo=False)
        self.nw.units.set_defaults(
            temperature="°C",
            pressure="bar",
            pressure_difference="bar",
            enthalpy="kJ / kg",
            entropy="kJ / kgK",
            power="MW",
            heat="MW",
            heat_transfer_coefficient="MW/K"
        )

        # components definition
        closer = CycleCloser("Cycle closer")

        cp1 = Compressor("Compressor 1")
        cp2 = Compressor("Compressor 2")

        rec1 = SectionedHeatExchanger("Recuperator 1", num_sections=10)
        rec2 = SectionedHeatExchanger("Recuperator 2", num_sections=10)

        cooler = SimpleHeatExchanger("Water cooler")
        heater = SimpleHeatExchanger("Heater")

        turb = Turbine("Turbine")

        sp1 = Splitter("Splitter 1")
        m1 = Merge("Merge 1")

        # connections definition
        # power cycle
        c1 = Connection(cooler, "out1", cp1, "in1", label="01")
        c2 = Connection(cp1, "out1", rec1, "in2", label="02")
        c3 = Connection(rec2, "out2", heater, "in1", label="03")

        c0 = Connection(heater, "out1", closer, "in1", label="00")
        c4 = Connection(closer, "out1", turb, "in1", label="04")
        c5 = Connection(turb, "out1", rec2, "in1", label="05")
        c6 = Connection(sp1, "out1", cooler, "in1", label="06")

        c10 = Connection(sp1, "out2", cp2, "in1", label="10")
        c11 = Connection(cp2, "out1", m1, "in2", label="11")
        c12 = Connection(rec1, "out2", m1, "in1", label="12")
        c13 = Connection(m1, "out1", rec2, "in2", label="13")

        c14 = Connection(rec2, "out1", rec1, "in1", label="14")
        c15 = Connection(rec1, "out1", sp1, "in1", label="15")

        # add connections to network
        self.nw.add_conns(c0, c1, c2, c3, c4, c5, c6, c10, c11, c12, c13, c14, c15)

        # power system
        grid = PowerSink("grid")
        distribution = PowerBus("distribution", num_in=1, num_out=3)
        motor1 = Motor("motor 1")
        motor2 = Motor("motor 2")
        generator = Generator("generator")

        e1 = PowerConnection(distribution, "power_out3", grid, "power", label="e1")

        e2 = PowerConnection(distribution, "power_out1", motor1, "power_in", label="e2")
        e3 = PowerConnection(motor1, "power_out", cp1, "power", label="e3")

        e4 = PowerConnection(distribution, "power_out2", motor2, "power_in", label="e4")
        e5 = PowerConnection(motor2, "power_out", cp2, "power", label="e5")

        e6 = PowerConnection(turb, "power", generator, "power_in", label="e6")
        e7 = PowerConnection(generator, "power_out", distribution, "power_in1", label="e7")

        self.nw.add_conns(e1, e2, e3, e4, e5, e6, e7)

        # heat input
        heatsource = HeatSource("heatsource")
        h1 = HeatConnection(heatsource, "heat", heater, "heat", label="h1")

        self.nw.add_conns(h1)

        motor1.set_attr(eta=0.97 * 0.98)
        motor2.set_attr(eta=0.97 * 0.98)
        generator.set_attr(eta=0.99 * 0.99)

        # connection parameters
        c1.set_attr(T=35, p=75, fluid={"CO2": 1})
        c2.set_attr(p=258.4)
        c3.set_attr(p=257)
        c4.set_attr(T=600, p=250)
        c5.set_attr(p=77.95)
        c6.set_attr(p=75.15)
        c11.set_attr(p=257.51, T=Ref(c12, 1, 0))
        c14.set_attr(p=76.94)

        # component parameters
        turb.set_attr(eta_s=0.9)
        cp1.set_attr(eta_s=0.85)
        cp2.set_attr(eta_s=0.85)
        rec1.set_attr(ttd_l=5)
        rec2.set_attr(ttd_l=5)

        # net power output
        e1.set_attr(E=100)

    def _parameter_lookup(self):
        return {
            "net power": ["Connections", "e1", "E"],
            "heat input": ["Connections", "h1", "E"],
            "turbine inlet temperature": ["Connections", "04", "T"],
            "turbine inlet pressure": ["Connections", "04", "p"],
            "compressor inlet temperature": ["Connections", "01", "T"],
            "compressor inlet pressure": ["Connections", "01", "p"],
            "turbine efficiency": ["Components", "Turbine", "eta_s"],
            "recuperator 1 pinch": ["Components", "Recuperator 1", "td_pinch"],
            "recuperator 2 pinch": ["Components", "Recuperator 2", "td_pinch"],
            "recuperator 1 ttd_l": ["Components", "Recuperator 1", "ttd_l"],
            "recuperator 2 ttd_l": ["Components", "Recuperator 2", "ttd_l"],
            "recuperator 1 UA": ["Components", "Recuperator 1", "UA"],
            "recuperator 2 UA": ["Components", "Recuperator 2", "UA"],
            "recuperator UA": {"get": self._get_recuperator_total_UA},
            "T recompressed in": ["Connections", "11", "T"],
            "T recuperated in": ["Connections", "12", "T"],
            "thermal efficiency": {"get": self._calc_thermal_efficiency},
            "split ratio": {"set": self._set_split_ratio, "get": self._get_split_ratio},
            "status": {"get": self._get_status}
        }

    def _get_recuperator_total_UA(self):
        return (
            self.get_parameter("recuperator 1 UA")
            + self.get_parameter("recuperator 2 UA")
        )

    def _get_status(self):
        return self.nw.status

    def _calc_thermal_efficiency(self):
        return (
            self.nw.get_conn("e1").E.val_SI
            / self.nw.get_conn("h1").E.val_SI
        )

    def _set_split_ratio(self, value):
        m = None
        c10, c15 = self.nw.get_conn(["10", "15"])
        if value is not None:
            m=Ref(c15, value, 0)
        c10.set_attr(m=m)

    def _get_split_ratio(self):
        c10, c15 = self.nw.get_conn(["10", "15"])
        return c10.m.val / c15.m.val

    def create_diagram(self, fluid_name):
        """Fluid property diagram with isolines suited for the cycle.

        The default isoline range of the base class ends at 200 °C and
        slightly above the critical pressure. The cycle however reaches
        turbine inlet temperatures of 600 °C and pressures of more than
        250 bar, so the temperature range and the pressure isolines are
        extended (including the internal iteration bound p_max, which the
        isotherms and isochores are calculated up to).
        """
        diagram = FluidPropertyDiagram(fluid_name)
        diagram.set_unit_system(self.nw.units)
        diagram.set_isolines_subcritical(-20, 650)
        diagram.set_isolines(
            p=np.array([1, 5, 10, 25, 50, 100, 150, 200, 250, 300])
        )
        diagram.p_max = 300e5
        diagram.calc_isolines()
        return diagram

    def solve_model(self, **kwargs):
        self.solve_model_design(**kwargs)

    def solve_design(self):
        self.nw.solve(mode="design")
        self.nw.assert_convergence()

    def run_exergy_analysis(self):
        from exerpy import ExergyAnalysis
        E_F = {"inputs": ["h1"], "outputs": []}
        E_P = {"inputs": ["e1"], "outputs": []}
        self._ean = ExergyAnalysis.from_tespy(
            self.nw, self.Tamb + 273.15, self.pamb * 1e5
        )
        # the cooler rejects heat to the ambient without a material cooling
        # stream; it is a dissipative component (as in the publication), its
        # exergy input is destroyed rather than delivered as a product
        self._ean.components["Water cooler"].dissipative = True
        self._ean.analyse(E_F=E_F, E_P=E_P)
        return self._ean

Design simulation

Instantiating the class builds the network once, solve_model runs the design simulation. The net power output is imposed at 100 MW.

model = SCO2Model()
model.solve_model()

indicators = [
    "net power", "heat input", "thermal efficiency",
    "turbine inlet temperature", "turbine inlet pressure",
    "compressor inlet temperature", "compressor inlet pressure",
]
pd.Series(model.get_results(indicators), name="design point").round(4)
net power                       100.0000
heat input                      245.9743
thermal efficiency                0.4065
turbine inlet temperature       600.0000
turbine inlet pressure          250.0000
compressor inlet temperature     35.0000
compressor inlet pressure        75.0000
Name: design point, dtype: float64

Validation

The reference publication proves temperature, pressure as well as thermal and mechanical specific exergies for every connection. All deviations are close to zero on the state variables. The mechanical exergy has the largest relative deviation, which comes from a different reference state implementation rather than from the cycle model.

published = pd.DataFrame(
    # label:  T in °C,   p in bar,  e_T in kJ/kg, e_M in kJ/kg
    [[  1,      35.00,     75.00,      7.59,     198.46],
     [  2,     123.26,    258.40,     34.86,     218.09],
     [  3,     433.63,    257.00,    231.28,     217.95],
     [  4,     600.00,    250.00,    362.76,     217.23],
     [  5,     456.95,     77.95,    213.80,     198.80],
     [  6,     128.26,     75.15,     33.56,     198.48],
     [ 10,     128.26,     75.15,     33.56,     198.48],
     [ 11,     264.11,    257.51,    116.43,     218.00],
     [ 12,     264.11,    257.51,    116.43,     218.00],
     [ 13,     264.11,    257.51,    116.43,     218.00],
     [ 14,     269.11,     76.94,     95.93,     198.68],
     [ 15,     128.26,     75.15,     33.56,     198.48]],
    columns=["label", "T", "p", "e_T", "e_M"],
).set_index("label")

tespy = pd.DataFrame({
    "T": {int(c.label): c.T.val for c in model.nw.conns["object"]
          if c.label.isdigit()},
    "p": {int(c.label): c.p.val for c in model.nw.conns["object"]
          if c.label.isdigit()},
}).sort_index()

comparison = published[["T", "p"]].join(
    tespy, lsuffix=" published", rsuffix=" tespy"
)
comparison["ΔT in K"] = comparison["T tespy"] - comparison["T published"]
comparison["δp in %"] = (
    (comparison["p tespy"] - comparison["p published"])
    / comparison["p published"] * 100
)
comparison.round(3)
T published p published T tespy p tespy ΔT in K δp in %
label
1 35.00 75.00 35.000 75.00 0.000 0.0
2 123.26 258.40 123.235 258.40 -0.025 0.0
3 433.63 257.00 433.693 257.00 0.063 0.0
4 600.00 250.00 600.000 250.00 0.000 0.0
5 456.95 77.95 457.141 77.95 0.191 0.0
6 128.26 75.15 128.235 75.15 -0.025 0.0
10 128.26 75.15 128.235 75.15 -0.025 0.0
11 264.11 257.51 264.139 257.51 0.029 0.0
12 264.11 257.51 264.139 257.51 0.029 0.0
13 264.11 257.51 264.139 257.51 0.029 0.0
14 269.11 76.94 269.139 76.94 0.029 0.0
15 128.26 75.15 128.235 75.15 -0.025 0.0

Ts diagram

We can make the cycle plot in the Ts diagram:

fig, ax = model.plot_Ts_diagram_matplotlib("01")
RuntimeWarning: divide by zero encountered in divide
RuntimeWarning: invalid value encountered in divide
RuntimeWarning: invalid value encountered in multiply
../_images/895606e3b53ab6aac7f97b2acc14cec81b6f4d399df93edce5e7946d38995f37.png

Sensitivity analysis

At the example of the paramters below a sensitivity analysis is carried out:

parameter

design value

range

turbine inlet temperature

600 °C

450 - 700 °C

compressor inlet temperature

35 °C

32 - 45 °C

compressor inlet pressure

75 bar

70 - 75 bar

# keep build time for docs low
n = 6 if os.getenv("TESPY_DOCS_BUILD") else 13

tit = model.sensitivity_analysis(
    param_dict={"turbine inlet temperature": np.linspace(450, 700, n)},
    result_param_list=["thermal efficiency", "heat input"],
)
cit = model.sensitivity_analysis(
    param_dict={"compressor inlet temperature": np.linspace(32, 45, n)},
    result_param_list=["thermal efficiency"],
)
cip = model.sensitivity_analysis(
    param_dict={"compressor inlet pressure": np.linspace(70, 75, n)},
    result_param_list=["thermal efficiency"],
)
# solve_model() re-solves with whatever parameters are currently set,
# so the design values are restated rather than assumed
model.solve_model(**{'turbine inlet temperature': 600, 'compressor inlet temperature': 35, 'compressor inlet pressure': 75})

The turbine inlet temperature has the stronges effect on efficiency. The compressor inlet conditions also have a quite high effect, mostly due to the fact, that the fluid near to the critical point, where the density is high and the compression work small. Moving away from that point reduces efficiency.

fig, ax = plt.subplots(1, 3, figsize=(12, 3.6))
ax[0].plot(tit["turbine inlet temperature"], tit["thermal efficiency"], "o-", color="tab:red")
ax[0].set_xlabel("turbine inlet temperature in °C")
ax[1].plot(cit["compressor inlet temperature"], cit["thermal efficiency"], "o-", color="tab:blue")
ax[1].set_xlabel("compressor inlet temperature in °C")
ax[1].axvline(31.0, ls="--", color="gray", lw=1)
ax[1].annotate(
    "critical\ntemperature", (31.0, cit["thermal efficiency"].min()),
    textcoords="offset points", xytext=(6, 4), fontsize=8, color="gray"
)
ax[2].plot(cip["compressor inlet pressure"], cip["thermal efficiency"], "o-", color="tab:green")
ax[2].set_xlabel("compressor inlet pressure in bar")
ax[2].axvline(73.8, ls="--", color="gray", lw=1)
ax[2].annotate(
    "critical\npressure", (73.8, cip["thermal efficiency"].min()),
    textcoords="offset points", xytext=(-38, 4), fontsize=8, color="gray"
)
for a in ax:
    a.set_ylabel("thermal efficiency")
    a.grid(alpha=.3)
plt.tight_layout()
plt.close(fig)
fig
../_images/85a7ac8c79881798d5b56570122b5bfdea123f0a66b71ba92d5d9bb69187c1bf.png

Changing the split ratio

The original data from the reference publication assume a specific split ratio of the mass flow between the compressor and the recomplressor. This is done implicitly through the temperature reference of connection 11. We can run a sensitivity analysis over this ratio:

c10, c11, c15 = (model.nw.get_conn(x) for x in ("10", "11", "15"))
c11.set_attr(T=None)  # release the matched merge temperature

result = model.sensitivity_analysis(
    param_dict={"split ratio": np.linspace(0.15, 0.30, 6)},
    result_param_list=["thermal efficiency", "T recompressed in", "T recuperated in", "status"],
)

result.set_index("split ratio").round(
    {"thermal efficiency": 3, "status": 0,
     "T recompressed in": 1, "T recuperated in": 1}
)
Invalid value for ttd_u: ttd_u = -9.769978344902142 below minimum value (0) at component Recuperator 1.
Invalid value for ttd_min: ttd_min = -9.769978344902142 below minimum value (0) at component Recuperator 1.
Invalid value for eff_cold: eff_cold = 1.0464114775961735 above maximum value (1) at component Recuperator 1.
Invalid value for eff_max: eff_max = 1.0464114775961735 above maximum value (1) at component Recuperator 1.
Invalid value for td_pinch: td_pinch = -9.769978344902142 below minimum value (0) at component Recuperator 1.
thermal efficiency T recompressed in T recuperated in status
split ratio
0.15 0.392 264.1 161.7 0
0.18 0.395 264.1 176.9 0
0.21 0.399 264.1 196.9 0
0.24 0.402 264.1 223.9 0
0.27 0.406 264.1 261.1 0
0.30 0.410 264.1 312.8 1

We monitor the highest efficiency at the highest split ratio, but we also see that the solution for that state is impossible. status==1 indicates a vioalation of what is physically possible. In this case, the recuperator 1 has a crossing of the two temperature profiles. We can prevent this, by imposing the minimum pinch instead of the terminal temperature differences.

model.solve_model(**{"split ratio": 0.3})
pd.DataFrame({
    label: {
        "ttd upper in K": model.nw.get_comp(label).ttd_u.val,
        "ttd lower in K": model.nw.get_comp(label).ttd_l.val,
        "pinch in K": model.nw.get_comp(label).td_pinch.val,
        "UA in MW/K": model.nw.get_comp(label).UA.val,
    }
    for label in ("Recuperator 1", "Recuperator 2")
}).round(3)
Invalid value for ttd_u: ttd_u = -9.769978344880201 below minimum value (0) at component Recuperator 1.
Invalid value for ttd_min: ttd_min = -9.769978344880201 below minimum value (0) at component Recuperator 1.
Invalid value for eff_cold: eff_cold = 1.0464114775960642 above maximum value (1) at component Recuperator 1.
Invalid value for eff_max: eff_max = 1.0464114775960642 above maximum value (1) at component Recuperator 1.
Invalid value for td_pinch: td_pinch = -9.769978344880201 below minimum value (0) at component Recuperator 1.
Recuperator 1 Recuperator 2
ttd upper in K -9.77 18.537
ttd lower in K 5.00 5.000
pinch in K -9.77 5.000
UA in MW/K NaN 19.044

We can also plot the temperature profiles:

fig, ax = plt.subplots(1, 2, figsize=(11, 4))

model.plot_QT_diagram_matplotlib("Recuperator 1", ax[0])
model.plot_QT_diagram_matplotlib("Recuperator 2", ax[1])
for a in ax:
    a.grid(alpha=.3)
plt.tight_layout()
plt.close(fig)
fig
../_images/3bb0084a80ceda3aa4cdb7e4d18ef525e3460db113a7d4367c3258f022d0ee79.png

So instead we switch the specifications to make use of td_pinch instead.

model.set_parameters(**{
    "recuperator 1 ttd_l": None, "recuperator 2 ttd_l": None,
    "recuperator 1 pinch": 5, "recuperator 2 pinch": 5
})

result = model.sensitivity_analysis(
    param_dict={"split ratio": np.linspace(0.15, 0.30, 6)},
    result_param_list=["thermal efficiency", "T recompressed in", "T recuperated in", "status"],
)

result.set_index("split ratio").round(
    {"thermal efficiency": 3, "status": 0,
     "T recompressed in": 1, "T recuperated in": 1}
)
thermal efficiency T recompressed in T recuperated in status
split ratio
0.15 0.392 264.1 161.7 0
0.18 0.395 264.1 176.9 0
0.21 0.399 264.1 196.9 0
0.24 0.402 264.1 223.9 0
0.27 0.406 264.1 261.1 0
0.30 0.397 272.1 272.1 0
fig, ax = plt.subplots(1, 2, figsize=(11, 4))

model.plot_QT_diagram_matplotlib("Recuperator 1", ax[0])
model.plot_QT_diagram_matplotlib("Recuperator 2", ax[1])
ax[0].set_title("Recuperator 1")
ax[1].set_title("Recuperator 2")
for a in ax:
    a.grid(alpha=.3)
plt.tight_layout()
plt.close(fig)
fig
../_images/b7ab3384a7e4859576b7ba703600f871abc3ee3d9b2e59f945758aede058068f.png

Recuperator size analysis

Instead of varying the split ratio, we carry out a sensitivity analysis on the sizing of the recuperators by imposing a lower or higher pinch value. For this we bring back the original split ratio defined by temperature equality and impose the lower terminal temperature difference. We look at recuperator 1 only at first:

model.set_parameters(
    **{"split ratio": None, "recuperator 1 pinch": None, "recuperator 1 ttd_l": 5}
)
c11, c12 = model.nw.get_conn(["11", "12"])
c11.set_attr(T=Ref(c12, 1, 0))
model.solve_model()

n = 6 if os.getenv("TESPY_DOCS_BUILD") else 12

sizing = model.sensitivity_analysis(
    param_dict={"recuperator 1 ttd_l": np.linspace(2, 18, n)},
    result_param_list=["thermal efficiency", "recuperator 1 UA"],
)

fig, ax = plt.subplots(1, 2, figsize=(11, 3.8))
ax[0].plot(sizing["recuperator 1 ttd_l"], sizing["thermal efficiency"], "o-", color="tab:blue")
ax[0].set_xlabel("recuperator 1 terminal temperature difference in K")
ax[0].set_ylabel("thermal efficiency")
ax[1].plot(sizing["recuperator 1 UA"], sizing["thermal efficiency"], "o-", color="tab:red")
ax[1].set_xlabel("recuperator 1 UA in MW/K")
ax[1].set_ylabel("thermal efficiency")
for a in ax:
    a.grid(alpha=.3)
plt.tight_layout()
plt.close(fig)
fig
../_images/55bbcd5775055d51edc25791c232a91ef137b4f243e5b23e01a788130eed371f.png

We can see, that the efficiency gain is reduced with an increae in the recuperator’s heat transfer coefficient, meaning a low temeprature difference increases the necessary area of the heat exchanger by a lot.

Optimizing the two recuperators

After the sensitivity analysis we optimize both heat exchangers simultaneously. We want to achieve high efficiency and low overall UA at the same time. With the NSGA-II algorithm we can make a pareto optimization.

Note

The generation count is reduced during a documentation build. Run the notebook interactively for a complete output.

n_gen = 3 if os.getenv("TESPY_DOCS_BUILD") else 25

model.set_parameters(**{"recuperator 2 pinch": None})
log, result = model.optimize(
    algorithm=NSGA2(pop_size=24),
    termination=("n_gen", n_gen),
    variables={
        "recuperator 1 ttd_l": {"min": 2, "max": 18},
        "recuperator 2 ttd_l": {"min": 2, "max": 18},
    },
    objective=["thermal efficiency", "recuperator UA"],
    minimize_flags=[False, True],
    kpi=["heat input"],
)
feasible = log.dropna(subset=["thermal efficiency", "recuperator UA"])
feasible.sort_values("thermal efficiency", ascending=False).head().round(3)
recuperator 1 ttd_l recuperator 2 ttd_l thermal efficiency recuperator UA heat input
68 2.161 4.235 0.411 48.946 243.172
54 2.190 4.320 0.411 48.520 243.232
35 2.181 4.776 0.411 46.856 243.412
58 3.755 4.424 0.409 44.415 244.641
36 3.645 4.817 0.409 43.248 244.705
fig, ax = plt.subplots(figsize=(10.5, 5.8))
sc = ax.scatter(
    feasible["recuperator UA"], feasible["thermal efficiency"],
    c=feasible["recuperator 1 ttd_l"], cmap="viridis", s=26,
)
fig.colorbar(sc, label="recuperator 1 ttd in K")
ax.set_xlabel("recuperator combined UA in MW/K")
ax.set_ylabel("thermal efficiency")
ax.grid(alpha=.3)
plt.close(fig)
fig
../_images/255760837762177b10aacb470b026639334c2cac7bb6d7b3c8bee6a71a8bb98f.png