Tutorial

This tutorial walks through writing a new mean-line class in order to design a compressor. Running actual CFD is out of scope for now.

We first need to install turbigen, which can be achieved on Linux or macOS with something like these shell commands:

$ curl -LsSf https://astral.sh/uv/install.sh | sh
$ source $HOME/.local/bin/env
$ uv tool install turbigen

Usually, we run turbigen by passing an input YAML file containing all the data required to construct a turbomachine. Your mean-line design algorithm, being code not data, must be written seperately in Python. turbigen finds user-written designs by walking up from the input file looking for a directory called turbigen_plugins. The below commands create a directory called tutorial, change into it, create the turbigen_plugins directory, and create an empty input file and a Python file for the design:

$ mkdir tutorial
$ cd tutorial
$ mkdir turbigen_plugins
$ touch input.yaml turbigen_plugins/fan.py

The file structure should then look like this:

$ tree
.
├── input.yaml
└── turbigen_plugins
    └── fan.py

2 directories, 2 files

Problem statement

Suppose we wish to design a rotor-only axial fan. We shall assume a constant axial velocity. The inlet state is specified as fixed values of \(T_{01}\) and \(p_{01}\) with no inlet swirl, \(\alpha_1=0\). We can then parametrise the aerodynamics of the stage using the following design variables (many other choices are possible):

  • Total pressure rise, \(\Delta p_0\)

  • Mass flow rate, \(\dot{m}\)

  • Flow coefficient, \(\phi=V_x/U\)

  • Loading coefficient, \(\psi= \Delta h_0/U^2\)

  • Hub-to-tip ratio, \(\mathit{HTR}=r_\mathrm{hub}/r_\mathrm{cas}\)

  • Total-to-total isentropic efficiency guess, \(\eta_\mathrm{tt}\)

To proceed with the annulus and blade shape construction, turbigen requires a mean line design: a nominal averaged flow field at the inlet and outlet of every blade row. For each of those stations we must supply

  • a thermodynamic state, \((h, s)\) for example

  • an absolute-frame velocity vector, \((V_x, V_r, V_\theta)\)

  • an annulus area, \(A_m\), and a mean radius, \(r_\mathrm{rms}\)

  • the angular velocity of the blade frame, \(\Omega\)

Mean-line design equations

We need to combine conservation of mass, momentum, and energy with definitions of our design variables to solve for the flow in the turbomachine. This will yield a set of equations that we will later implement numerically in a method on our new class.

The specified total pressure rise and guess of total-to-total efficiency allow calculation of the compressor work \(\Delta h_0 = h_{02}-h_{01}\)

(1)\[\eta_\mathrm{tt} = \frac{h_{02s}-h_{01}}{h_{02}-h_{01}} \quad\Rightarrow\quad \Delta h_0 = \frac{1}{\eta_\mathrm{tt}}\left[h(p_{01}+\Delta p_0, s_1) - h_{01}\right]\]

where \(h_{02s}=h(p_{01}+\Delta p_0, s_1)\) is the ideal exit stagnation enthalpy for isentropic compression, i.e. enthalpy evaluated at the (lossy) exit stagnation pressure and (lossless) inlet entropy.

The blade speed \(U\) is then given from the definition of loading coefficient

(2)\[\psi = \frac{\Delta h_0}{U^2} \quad\Rightarrow\quad U = \sqrt{\frac{\Delta h_0}{\psi}}\]

The definition of flow coefficient yields the axial velocity \(V_x\)

(3)\[\phi = \frac{V_x}{U} \quad\Rightarrow\quad V_x = \phi U\]

Assuming no inlet swirl, \(V_{\theta 1}=0\) and the Euler work equation yields the rotor exit circumferential velocity

(4)\[\Delta h_0 = U\left(V_{\theta 2}-V_{\theta 1}\right)\quad\Rightarrow\quad V_{\theta 2}=\frac{\Delta h_0}{U}\]

We now have stagnation thermodynamic states and velocity vectors at inlet and exit of the rotor. Static enthalpy follows from subtracting the kinetic energy at constant entropy, \(h = h_0 - \tfrac{1}{2}V^2\); entropy does not depend on the frame of reference. Now, passing \((h, s)\) to the equation of state will yield density \(\rho\) or any other static thermodynamic property as needed.

Conservation of mass then gives the annulus area at each station. Subscript \(m\) denotes the meridional projection with no circumferential component, as opposed to the flow area which is normal to the velocity vector (and thus dependent on flow angle).

(5)\[\dot{m} = \rho A_m V_x \quad\Rightarrow\quad A_m = \frac{\dot{m}}{\rho V_x}\]

and further specifying a hub-to-tip ratio fixes the mean radius

\[A_m = \pi\left(r_\mathrm{cas}^2 - r_\mathrm{hub}^2\right)\,,\ r_\mathrm{rms} = \sqrt{\frac{1}{2}\left(r_\mathrm{cas}^2 + r_\mathrm{hub}^2\right)} \,,\ \mathit{HTR}=\frac{r_\mathrm{hub}}{r_\mathrm{cas}}\]
(6)\[\Rightarrow r_\mathrm{rms} = \sqrt{\frac{A_m}{2\pi}\frac{1+\mathit{HTR}^2}{1-\mathit{HTR}^2}}\]

Finally, the shaft angular velocity is simply

(7)\[\Omega = U/r_\mathrm{rms}\]

Setting up the files

A mean-line design is a subclass of MeanLineDesign which has two methods: forward(), which turns design variables into a flow field MeanLine, and backward() , which recovers the design variables from a MeanLine built by forward() or averaged from a CFD solution. The The design contract sets out in full what such a subclass declares and what it inherits.

Copy the below skeleton of the class into turbigen_plugins/fan.py. The type string is the name a input file will use to ask for the new turbomachine, and n_row says how many blade rows it describes:

turbigen_plugins/fan.py
from turbigen.design import MeanLineDesign


class Fan(MeanLineDesign):
    """A single-row axial fan."""

    type: str = "fan"
    n_row: int = 1

    def forward(self, fluid):
        """Return a mean line built from this design's variables."""
        raise NotImplementedError("Implement the forward method")

    def backward(self, ml):
        """Return the design variables represented by mean line `ml`."""
        raise NotImplementedError("Implement the backward method")

We can now copy into input.yaml a skeleton configuration that asks for this design, but in order for the mean-line design to run we also need to specify the working fluid. The fluid: section names a fluid by its type and sets any properties needed to specify that type.

input.yaml
fluid:
  type: perfect  # Which fluid model, perfect or real
  cp: 1005.0     # Specific heat at constant pressure [J/kg/K]
  gamma: 1.4     # Ratio of specific heats [--]
  mu: 1.8e-5     # Dynamic viscosity [Pa*s]

mean_line:
  type: fan    # Which plugin to use, compressor/turbine etc

If we ask turbigen to run the design for input.yaml at this point, it will load the input data but stop because forward is not implemented.

$ turbigen design input.yaml
*** TURBIGEN v3.0.0 ***
...
NotImplementedError: Implement the forward method
Total time: 0.0 s

Design variables

We now need to declare the design variables we need to take from the input file, by adding them as fields to the class.

turbigen_plugins/fan.py
# ...
class Fan(MeanLineDesign):
    """A single-row axial fan."""

    type: str = "fan"
    n_row: int = 1

    DPo: float
    """Stagnation pressure rise across the rotor [Pa]."""

    mdot: float
    """Mass flow rate [kg/s]."""

    phi: float
    """Inlet flow coefficient [--]."""

    psi: float
    """Stage loading coefficient [--]."""

    htr: float
    """Inlet hub-to-tip ratio [--]."""

    eta_tt: float
    """Total-to-total isentropic efficiency [--]."""

    Po1: float = 1e5
    """Inlet stagnation pressure [Pa]."""

    To1: float = 300.0
    """Inlet stagnation temperature [K]."""

# ...

Optionally, a default can be set for a field, which will be used if the input file does not specify a value; the input file must include all fields without defaults. Hence, running the design now will produce a different error:

$ turbigen design input.yaml
*** TURBIGEN v3.0.0 ***
...
TypeError: Fan.__init__() missing 6 required keyword-only arguments: 'DPo', 'mdot', 'phi', 'psi', 'htr', and 'eta_tt'
Total time: 0.0 s

Note that To1 and Po1 are not mentioned in the error message, because we specified their defaults in the class (although the input file can override those defaults if needed). Finally, before moving to implementing the algorithm, add values for the missing design variables to input.yaml:

input.yaml
# ...
mean_line:
  type: fan    # Which plugin to use, compressor/turbine etc
  DPo: 2000.0     # Stagnation pressure rise [Pa]
  mdot: 5.0       # Mass flow rate [kg/s]
  phi: 0.5        # Flow coefficient
  psi: 0.4        # Loading coefficient
  htr: 0.8        # Inlet hub-to-tip ratio
  eta_tt: 0.9     # Efficiency guess

Forward method

We can now code up the Mean-line design equations. The forward method takes a fluid object as an argument, and can access the design variables as attributes of self. The method must return a MeanLine object with the flow field filled in.

The fluid object is the equation of state named by fluid: in the input file, whose interface is documented in ember.fluid. That interface is a pair of method families: a set_X_Y takes the two properties named and returns the density and internal energy pair that fixes the state, and a get_Z evaluates one property from that pair. The full list of both is in that reference; the ones used below are all this design needs.

The Fan class specifies inlet stagnation pressure and temperature, but enthalpy and entropy are more convinient to work with. fluid.set_P_T() returns the density and internal energy pair for a pressure and a temperature, which are then passed to fluid.get_h() and fluid.get_s() to evaluate stagnation enthalpy and entropy (noting that entropy does not depend on the frame of reference):

def forward(self, fluid):
    # Inlet stagnation enthalpy and entropy
    rhoo1, uo1 = fluid.set_P_T(self.Po1, self.To1)
    ho1 = fluid.get_h(rhoo1, uo1)
    s1 = fluid.get_s(rhoo1, uo1)

At no point have we used perfect gas relations — a design class should make no assumptions about the equation of state.

To evaluate Eqn. (1) we straightforwardly calculate exit stagnation pressure from the specified pressure rise, and pass it together with inlet entropy through fluid.set_P_s() and fluid.get_h() to evaluate the ideal exit enthalpy. Then rearrange and use the definition of efficiency to find the work done:

    # Ideal exit stagnation enthalpy
    Po2 = self.Po1 + self.DPo
    ho2s = fluid.get_h(*fluid.set_P_s(Po2, s1))

    # Work from the definition of efficiency
    Dho = (ho2s - ho1) / self.eta_tt

The blade speed and velocities then follow directly from the definitions Eqns. (2) to (4):

    # Blade speed from the definition of loading coefficient
    U = np.sqrt(Dho / self.psi)

    # Axial velocity from the definition of flow coefficient
    Vx = self.phi * U

    # Exit swirl from the Euler work equation, no inlet swirl
    Vt2 = Dho / U

The exit entropy is set by the actual work and pressure rise, evaluated with fluid.set_P_h() and fluid.get_s(). Then with entropy, stagnation enthalpy, and velocity known at both stations, the static states come from subtracting kinetic energy.

    # Exit entropy from actual work and pressure rise
    ho2 = ho1 + Dho
    s2 = fluid.get_s(*fluid.set_P_h(Po2, ho2))

    # Static enthalpy from stagnation enthalpy less KE
    Vt = np.array([0.0, Vt2])
    ho = np.array([ho1, ho2])
    s = np.array([s1, s2])
    h = ho - 0.5 * (Vx**2 + Vt**2)

We can now store the flow field in a MeanLine object, a blank instance of which is created by calling allocate(). The indexing convention for mean line stations is that ml.shape == (2, n_row), where the first index is the station (0 for inlet, 1 for exit) and the second index is the row number. It is often more convenient to work with a one-dimensional view of shape (2*n_row,), which can be obtained through the ml.flat property. All data flows go through setter methods on the MeanLine object that ensure the flow field is consistent and valid. See Mean-line flow field for the full reference of what is stored, how it is indexed, and which setters are available. In our case:

    # Store the flow field
    ml = self.allocate(fluid)
    flat = ml.flat
    flat.set_h_s(h, s)
    flat.set_Vx(Vx)
    flat.set_Vr(0.0)
    flat.set_Vt(Vt)

Now that the flow field is stored, we can use attributes on the MeanLine object to evaluate derived quantities automatically. For example, we need density to set the annulus area via conservation of mass, Eqn. (5), which we can access as ml.flat.rho. The hub-to-tip ratio then fixes the mean radius, Eqn. (6), from which the shaft speed follows by Eqn. (7):

    # Conservation of mass sets the annulus areas
    flat.set_Am(self.mdot / flat.rho / Vx)

    # Fix a constant mean radius using inlet HTR
    Am1 = flat.Am[0]
    r_rms = np.sqrt(Am1 / (2.0 * np.pi) * (1.0 + self.htr**2) / (1.0 - self.htr**2))
    ml.set_r_rms(r_rms)
    ml.set_Omega(U / r_rms)

That is every quantity the mean line needs, so forward can now return it. In full:

turbigen_plugins/fan.py
def forward(self, fluid):
    # Inlet stagnation enthalpy and entropy
    rhoo1, uo1 = fluid.set_P_T(self.Po1, self.To1)
    ho1 = fluid.get_h(rhoo1, uo1)
    s1 = fluid.get_s(rhoo1, uo1)

    # Ideal exit stagnation enthalpy
    Po2 = self.Po1 + self.DPo
    ho2s = fluid.get_h(*fluid.set_P_s(Po2, s1))

    # Work from the definition of efficiency
    Dho = (ho2s - ho1) / self.eta_tt

    # Blade speed from the definition of loading coefficient
    U = np.sqrt(Dho / self.psi)

    # Axial velocity from the definition of flow coefficient
    Vx = self.phi * U

    # Exit swirl from the Euler work equation, no inlet swirl
    Vt2 = Dho / U

    # Exit entropy from actual work and pressure rise
    ho2 = ho1 + Dho
    s2 = fluid.get_s(*fluid.set_P_h(Po2, ho2))

    # Static enthalpy from stagnation enthalpy less KE
    Vt = np.array([0.0, Vt2])
    ho = np.array([ho1, ho2])
    s = np.array([s1, s2])
    h = ho - 0.5 * (Vx**2 + Vt**2)

    # Store the flow field
    ml = self.allocate(fluid)
    flat = ml.flat
    flat.set_h_s(h, s)
    flat.set_Vx(Vx)
    flat.set_Vr(0.0)
    flat.set_Vt(Vt)

    # Conservation of mass sets the annulus areas
    flat.set_Am(self.mdot / flat.rho / Vx)

    # Fix a constant mean radius using inlet HTR
    Am1 = flat.Am[0]
    r_rms = np.sqrt(Am1 / (2.0 * np.pi) * (1.0 + self.htr**2) / (1.0 - self.htr**2))
    ml.set_r_rms(r_rms)
    ml.set_Omega(U / r_rms)

    return ml

If we now run turbigen on input.yaml, it will load the input data, build the mean line, and then fall over because backward is not implemented:

$ turbigen design input.yaml
*** TURBIGEN v3.0.0 ***
...
NotImplementedError: Implement the backward method
Total time: 0.0 s

Backward method

backward is the encapsulation of what each design variable means. It is a check that the mean line forward built really is the design that was asked for and it is also how design variables are calculated from a mixed-out CFD solution to compare to the nominal design.

backward returns a dictionary keyed by the field names; extra keys beyond the design variables are reported for information only but not checked for consistency, as described under Design process. The MeanLine has many attributes that contain useful derived properties for this purpose — see the derived properties tables. The properties ml.inlet and ml.outlet index into the machine inlet and outlet (across all rows).

turbigen_plugins/fan.py
def backward(self, ml):
    return {
        # Design variables
        "DPo": ml.outlet.Po - ml.inlet.Po,
        "mdot": ml.inlet.mdot,
        "phi": ml.inlet.Vx / ml.inlet.U,
        "psi": (ml.outlet.ho - ml.inlet.ho) / ml.inlet.U**2,
        "htr": ml.inlet.htr,
        "eta_tt": ml.eta_tt,
        "Po1": ml.inlet.Po,
        "To1": ml.inlet.To,
        # Optional diagnostic variables
        "PR_tt": ml.PR_tt,
        "eta_ts": ml.eta_ts,
    }

With both methods written, the design runs to completion. turbigen calls forward to build the mean line, passes it back through backward to check that the design variables it asked for are the ones it got, and prints the result — the Design process in full:

$ turbigen design input.yaml
*** TURBIGEN v3.0.0 ***
...
Mean line:          Row 0     
                 Inlet  Outlet
Po/bar           1.000   1.020
To/K            300.00  301.89
Ma               0.099   0.127
Ma_rel           0.222   0.155
Alpha/deg          0.0    38.7
Alpha_rel/deg    -63.4   -50.2
Total time: 0.0 s

Annulus and blade shapes

With the mean line built, we can now construct the annulus lines and blades by specifying some more input data.

To use the built-in annulus requires only a few fairly self-explanatory lines in the input file:

input.yaml
# ...
annulus:
  type: aspect_ratio  # How to select axial chord
  AR_row: [3.0]       # span/chord ratio of the row
  AR_gap: [1.0, 1.0]  # span/length ratio of inlet and exit ducts

# ...

Blade specification is more involved. We need to choose how many blades in each row; the Lieblein diffusion factor is a good starting point for compressors. We can construct the blade sections at any number spanwise positions by choosing camber and thickness distribution, with the camber and thickness parameters smoothly interpolated across the span.

input.yaml
# ...
blades:
  # List of rows
  - count:
      type: DFL  # Which method to set number of blades
      DFL: 0.45  # Lieblein diffusion factor value
    sections:
      # List of sections
      - spf: 0.5  # Span fraction of the section
        dchi_LE: 0.0  # Leading edge recamber [deg]
        dchi_TE: 0.0  # Trailing edge recamber [deg]
        camber:
          # Simple quadratic camber line
          type: quadratic
          aft_loading: 0.0
        thickness:
          # Piecewise cubic thickness distribution
          type: taylor  # After Taylor and Miller (2017)
          R_LE: 0.02  # Leading edge radius/chord
          t_max: 0.06  # Maximum thickness/chord
          m_tmax: 0.35  # Chord fraction at maximum thickness
          kappa_max: 0.0  # Curvature at maximum thickness
          t_TE: 0.01  # Trailing edge thickness/chord
          tanwedge: 0.05  # Trailing edge wedge angle tangent

Now passing the input file to turbigen will now build the mean line and the annulus and blade shapes to give us some more printout, including the calculated meridional chord, number of blades, tip gap, and pitch-to-chord ratio:

$ turbigen design input.yaml
*** TURBIGEN v3.0.0 ***
...
Mean line:          Row 0     
                 Inlet  Outlet
Po/bar           1.000   1.020
To/K            300.00  301.89
Ma               0.099   0.127
Ma_rel           0.222   0.155
Alpha/deg          0.0    38.7
Alpha_rel/deg    -63.4   -50.2
Annulus:            Row 0     
                 Inlet  Outlet
r_rms/m         0.3017  0.3017
r_hub/m         0.2666  0.2670
r_tip/m         0.3332  0.3329
Am/m2           0.1256  0.1243
htr             0.8000  0.8019
cx/m               0.0221     
AR                 3.0000     
Blades:          Row 0
N_blade             55
Gap/m           0.0000
s/cm             1.578
Total time: 0.0 s

Plotting

So far, our invocation of turbigen has been in design mode, which only prints to the console. We can save more detailed plots to disk by running report mode, which will write a post.pdf report with plots of the design, and if CFD were run the post-processed flow field as well. Report mode also saves an output.yaml file which is the same as the input file but with any defaults filled in, and a transcript of the console output in log_turbigen.txt. So if we run:

$ turbigen report input.yaml
*** TURBIGEN v3.0.0 ***
...
No solution was marched, skipping the convergence plot.
No solved grid, skipping the surface distribution plot.
No grid to cut, skipping the contour plot.
Wrote report to /home/runner/work/turbigen/turbigen/tutorial/step5/post.pdf
Wrote resolved configuration to /home/runner/work/turbigen/turbigen/tutorial/step5/output.yaml
Total time: 0.6 s
Output directory was: /home/runner/work/turbigen/turbigen/tutorial/step5

The flow-field plots are skipped because no CFD has been run; the annulus, blade section and velocity triangle plots are drawn from the geometry alone. All of the output is written alongside the input file:

$ ls
input.yaml
log_turbigen.txt
output.yaml
post.pdf
turbigen_plugins

Opening post.pdf shows the mean-line velocity triangles, the meridional annulus view, the blade-to-blade sections of each row:

_images/tut_step5_triangle.svg
_images/tut_step5_annulus.svg
_images/tut_step5_sections.svg

Extensions

This tutorial has demonstrated some of the functionality of turbigen. We have now designed a new turbomachine and had a look at the resulting geometry.

Within the current choice of mean-line parameterisation, any change to the design is just an edit to input.yaml:

  • Change the number of blades by changing the diffusion factor DFL, or fix a count directly with count: {type: Nb, Nb: 55}

  • Reshape the blade through the camber and thickness sections

  • Specify blade sections at several spanwise locations

  • Change the aspect ratio AR_row

  • Change the working fluid to a real gas under fluid:, evaluated from a thermodynamically consistent fitted equation of state [Wheeler, 2024]

To change the mean-line design itself, edit forward and backward in fan.py. For example: relax the assumption of constant axial velocity by adding a velocity ratio as a field, replace the loading coefficient with a de Haller number, or specify an inlet Mach number instead of a mass flow rate. The last two cannot be built in one pass like the design variables here, because the flow field has to be known before either can be evaluated; see Implicit design problems for how to solve for them.

To add a stator, raise n_row to 2 and extend forward to fill in four stations rather than two. Rows are indexed by the second axis, so ml[:, 0] will be the rotor and ml[:, 1] the stator, each of shape (2,); ml[0] and ml[1] remain the inlet and outlet stations of every row. We need to call ml[:, 0].set_Omega to make only the rotor spin. ml.flat will now be of shape (4,), indexed from machine inlet to machine outlet.