Isentropic Vortex in 2D
This example is taken from Spiegel et al. (2015), namely Section IV.A.1. Hence, the exact solution is based on the Gaussian pulse
where \(f(x,y) = -\frac{1}{2\sigma^2} \Big( r^2/ R^2 \Big)\), with \(r^2 = (x-x_c)^2 + (y-y_c)^2\) and \((x_c, y_c)\) corresponds to the initial location of the pulse. Using this function, we define the perturbations to the velocity and temperature as
with \(\overline{\gamma} = \gamma - 1\) and the specific heat ratio is taken as \(\gamma = 1.4\).
Afterwards, these perturbations along with an isentropic flow assumption (\(\delta s = 0\), where \(s = p/\rho^\gamma\)), we generate the analytic solution. In primitive variables, this reads
where \(M_\infty\) is the mean Mach number and \(\alpha\) is the flow angle, taken with respect to the \(x\)-axis.
Following d’Alembert’s solution, the above analytic solution at any given time is simply the shift of the initial condition (\(t = t_0\)). This can be represented by substituting the initial coordinates \(x\) and \(y\) with
where \((u_\infty, v_\infty)\) is the mean velocity, obtained from the Mach number and flow direction via \(u_\infty = M_\infty \cos \alpha\) and \(v_\infty = M_\infty \sin \alpha\).
For this specific example, the parameters used are: a mean Mach number of \(M_\infty = 0.5\), a Gaussian amplitude of \(\beta = M_\infty \frac{5 \sqrt{2}}{4 \pi} e^{1/2}\) with a characteristic width of \(R = 1\), a flow angle of \(\alpha = 45^{\circ}\) on a grid \(\boldsymbol{x} \in [-5,+5]^2\) with an initial pulse center situated at \((x_c, y_c) = (0, 0)\). The total duration of this simulation is taken as \(t \in [0,5]\). Note, all variables are non-dimensional, by definition.
[1]:
# Import preliminary modules.
from dream import *
from dream.compressible import Initial, CompressibleFlowSolver, flowfields
import ngsolve as ngs
[2]:
# Define the grid.
from netgen.occ import OCCGeometry, WorkPlane
from netgen.meshing import IdentificationType
def CreateSimpleGrid(ne, lx, ly):
    # Select a common element size.
    h0 = min( lx, ly )/float(ne)
    # Generate a simple rectangular geometry.
    domain = WorkPlane().RectangleC(lx, ly).Face()
    # Assign the name of the internal solution in the domain.
    domain.name = 'internal'
    # For convenience, extract and name each of the edges consistently.
    bottom = domain.edges[0]; bottom.name = 'bottom'
    right  = domain.edges[1]; right.name  = 'right'
    top    = domain.edges[2]; top.name    = 'top'
    left   = domain.edges[3]; left.name   = 'left'
    # Pair the boundaries in each direction: vertical and horizontal.
    bottom.Identify(top, "ydir", IdentificationType.PERIODIC)
    right.Identify(left, "xdir", IdentificationType.PERIODIC)
    # Initialize a rectangular 2D geometry.
    geo = OCCGeometry(domain, dim=2)
    # Discretize the domain.
    mesh = ngs.Mesh(geo.GenerateMesh(maxh=h0, quad_dominated=True))
    # Return our fancy grid.
    return mesh
[3]:
# Generate the grid.
# Number of elements per dimension.
ne = 10
# Dimension of the rectangular domain.
lx = 10.0
ly = 10.0
# Generate a simple grid.
mesh = CreateSimpleGrid(ne, lx, ly)
# Message output detail from netgen.
ngs.ngsglobals.msg_level = 0
ngs.SetNumThreads(4)
[4]:
# Class containing all the parameters to define an isentropic vortex.
class IsentropicVortexParam:
    def __init__(self, cfg):
        # Center of the vortex, assumed at the center of the domain.
        # Currently, this assumes that the domain is: [0,lx],[0,ly].
        self.x0     = 0.0
        self.y0     = 0.0
        # Vortex parameters, found in Spiegel et al. (2015).
        self.theta  = 45.0 # flow angle [deg].
        self.Tinf   = 1.0  # background temperature.
        self.Pinf   = 1.0  # background pressure.
        self.Rinf   = 1.0  # background density.
        self.Minf   = 0.5  # background Mach.
        self.sigma  = 1.0  # Perturbation strength.
        self.Rv     = 1.0  # Perturbation width.
        # Scaling for the maximum strength of the perturbation.
        self.beta   = self.Minf*ngs.exp(0.5)*5.0*ngs.sqrt(2.0)/(4.0*ngs.pi)
        # Convert the angle from degrees to radians.
        self.theta *= ngs.pi/180.0
        # Deduce the background mean velocity.
        self.uinf   = self.Minf*ngs.cos( self.theta )
        self.vinf   = self.Minf*ngs.sin( self.theta )
        # Store gamma here, so we do not pass it around constantly.
        self.gamma  = cfg.equation_of_state.heat_capacity_ratio
# Function that defines the analytic solution.
def InitialCondition(cfg):
    # Extract the starting time.
    t0 = cfg.time.timer.interval[0]
    # Return the analytic solution at time: t0.
    return AnalyticSolution(cfg, t0)
# Function that creates a time-dependant analytic solution.
def AnalyticSolution(cfg, t):
    # Extract the vortex parameters.
    vparam = IsentropicVortexParam(cfg)
    # Generate an array of 4x3 vortices. If you need something different, modify it.
    # NOTE, in this case, the 4x3 array of vortices assumes:
    #  1) flow aligned in (+ve) x-direction.
    #  2) simulation is done for only 1 period: tf = lx/uInf.
    fn = ngs.CF(())
    for i in range(-2,2):
        for j in range(-1,2):
            fn += GeneratePerturbation(vparam, t, i, j)
    # For convenience, extract the perturbations explicitly.
    dT = fn[0]; du = fn[1]; dv = fn[2]
    # Extract the required parameters to construct the actual variables.
    gamma  = vparam.gamma
    uinf   = vparam.uinf
    vinf   = vparam.vinf
    # Abbreviations involving gamma.
    gm1    =  gamma - 1.0
    ovg    =  1.0/gamma
    ovgm1  =  1.0/gm1
    govgm1 =  gamma*ovgm1
    # Define the primitive variables, by superimposing the perturbations on a background state.
    r = (1.0 + dT)**ovgm1
    u = uinf + du
    v = vinf + dv
    p = ovg*(1.0 + dT)**govgm1
    # Return the analytic expression of the vortex.
    return flowfields( rho=r, u=(u, v), p=p )
# Function that generates a single isentropic perturbations in the velocity and temperature.
# Here, (ni,nj) are the integers for the multiple of (lx,ly)
# distances between the leading/trailing vortices.
def GeneratePerturbation(vparam, t, ni, nj):
    # For convenience, extract the information of the vortex.
    theta = vparam.theta
    Tinf  = vparam.Tinf
    Pinf  = vparam.Pinf
    Rinf  = vparam.Rinf
    Minf  = vparam.Minf
    sigma = vparam.sigma
    Rv    = vparam.Rv
    beta  = vparam.beta
    gamma = vparam.gamma
    x0    = vparam.x0
    y0    = vparam.y0
    uinf  = vparam.uinf
    vinf  = vparam.vinf
    # Center of the pulse.
    xc = x0 + ni*lx
    yc = y0 + nj*ly
    # Time-dependent pulse center.
    xt = (ngs.x-xc) - uinf*t
    yt = (ngs.y-yc) - vinf*t
    # Abbreviations involving gamma.
    gm1    =  gamma - 1.0
    ovg    =  1.0/gamma
    ovgm1  =  1.0/gm1
    govgm1 =  gamma*ovgm1
    ovs2   =  1.0/(sigma*sigma)
    # The Gaussian perturbation function.
    ovRv   =  1.0/Rv
    f      = -0.5*ovs2*( (xt/Rv)**2 + (yt/Rv)**2 )
    Omega  =  beta*ngs.exp(f)
    # Velocity and temperature perturbations.
    du = -ovRv*yt*Omega
    dv =  ovRv*xt*Omega
    dT = -0.5*gm1*Omega**2
    # Return the Perturbations.
    return ngs.CF( (dT, du, dv) )
[5]:
# Solver configuration: Compressible (inviscid) flow.
cfg = CompressibleFlowSolver(mesh)
cfg.time = "transient"
cfg.time.timer.interval = (0.0, 5.0)
cfg.time.timer.step = 0.25
cfg.dynamic_viscosity = "inviscid"
cfg.equation_of_state = "ideal"
cfg.equation_of_state.heat_capacity_ratio = 1.4
cfg.scaling = "acoustic"
cfg.mach_number = 0.0
cfg.riemann_solver = "lax_friedrich"
cfg.fem = "conservative_hdg"
cfg.fem.order = 0
cfg.fem.scheme = "implicit_euler"
cfg.fem.solver = "direct"
cfg.fem.solver.method = "newton"
cfg.fem.solver.method.damping_factor = 1
cfg.fem.solver.method.max_iterations = 10
cfg.fem.solver.method.convergence_criterion = 1e-10
Uic = InitialCondition(cfg)
cfg.bcs['left|right'] = "periodic"
cfg.bcs['top|bottom'] = "periodic"
cfg.dcs['internal'] = Initial(fields=Uic)
[6]:
# Decorator for the actual simulation.
import numpy as np
# Extract time configuration.
t0, tf = cfg.time.timer.interval
dt = cfg.time.timer.step.Get()
nt = int(round((tf - t0) / dt))
# Abbreviations.
gamma = cfg.equation_of_state.heat_capacity_ratio
# Define a decorator for the isentropic vortex routine.
def isentropic_vortex_routine(label):
    def decorator(func):
        def wrapper(*args, draw_solution=False, **kwargs):
            # Insert options here.
            func(*args, **kwargs)
            cfg.fem.order = 4
            # Allocate the necessary data.
            cfg.initialize()
            # Get a reference to the numerical solution and the analytic solution.
            uh = cfg.get_solution_fields('rho_u')
            ue = AnalyticSolution(cfg, cfg.time.timer.t)
            if draw_solution:
                # cfg.io.draw({"Density": uh.rho})
                # cfg.io.draw({"Mach": cfg.get_local_mach_number(uh)})
                # cfg.io.draw({"Exact[Density]": ue.rho})
                isentropic_deviation = (uh.p/ue.p) * (ue.rho/uh.rho)**gamma - 1.0
                cfg.io.draw({"Deviation[Isentropy]": isentropic_deviation}, min=-0.0001, max=0.0001)
                #cfg.io.draw({"Diff[Density]": (ue.rho - uh.rho)}, min=-0.1, max=0.1)
            # NOTE, uncomment if you want the norm based on magnitude of the momentum.
            mag_rhou_e = ue.rho * ngs.sqrt( ngs.InnerProduct(ue.u, ue.u) )
            mag_rhou_n = ngs.sqrt( ngs.InnerProduct(uh.rho_u, uh.rho_u) )
            # Integration order (for post-processing).
            qorder = 10
            # Data for book-keeping information.
            data = np.zeros((nt, 3), dtype=float)
            with ngs.TaskManager():
                # Time integration loop  (actual simulation).
                for i, t in enumerate(cfg.time.start_solution_routine(True)):
                    # Compute the L2-norm of the error.
                    err_rho = np.sqrt( ngs.Integrate( (ue.rho-uh.rho)**2, mesh, order=qorder) )
                    err_mom = np.sqrt( ngs.Integrate( (mag_rhou_e-mag_rhou_n)**2, mesh, order=qorder) )
                    # Store data: time and error metrics.
                    data[i] = [cfg.time.timer.t.Get(), err_rho, err_mom]
            return data
        wrapper.label = label
        return wrapper
    return decorator
[7]:
# Specialized routines.
@isentropic_vortex_routine("implicit_euler(hdg)")
def implicit_euler_hdg():
    cfg.fem = "conservative_hdg"
    cfg.fem.scheme = "implicit_euler"
@isentropic_vortex_routine("bdf2(hdg)")
def bdf2_hdg():
    cfg.fem = "conservative_hdg"
    cfg.fem.scheme = "bdf2"
@isentropic_vortex_routine("sdirk22(hdg)")
def sdirk22_hdg():
    cfg.fem = "conservative_hdg"
    cfg.fem.scheme = "sdirk22"
@isentropic_vortex_routine("sdirk33(hdg)")
def sdirk33_hdg():
    cfg.fem = "conservative_hdg"
    cfg.fem.scheme = "sdirk33"
@isentropic_vortex_routine("imex_rk_ars443(dg)")
def imex_rk_ars443_dg():
    cfg.fem = "conservative_dg"
    cfg.fem.scheme = "imex_rk_ars443"
@isentropic_vortex_routine("ssprk3(dg)")
def ssprk3_dg():
    cfg.fem = "conservative_dg"
    cfg.fem.scheme = "ssprk3"
@isentropic_vortex_routine("crk4(dg)")
def crk4_dg():
    cfg.fem = "conservative_dg"
    cfg.fem.scheme = "crk4"
@isentropic_vortex_routine("explicit_euler(dg)")
def explicit_euler_dg():
    cfg.fem = "conservative_dg"
    cfg.fem.scheme = "explicit_euler"
[8]:
# Figure style and properties.
from matplotlib import pyplot as plt
# Assign colors to base method names
method_colors = {
    "implicit_euler": "tab:red",
    "bdf2": "tab:blue",
    "sdirk22": "tab:green",
    "sdirk33": "tab:purple"
}
# Helper function that sets up a generic figure.
def setup_fig(xlabel, ylabel, title):
    fig, ax = plt.subplots(figsize=(15, 6))
    ax.set_xlim(t0, tf)
    ax.tick_params(axis='both', labelsize=16)
    ax.set_xlabel(xlabel, fontsize=14)
    ax.set_ylabel(ylabel, fontsize=16)
    ax.set_title(title, fontsize=16)
    ax.grid(True)
    return fig,ax
# Helper function that specifies the plot style properties.
def get_plot_style(label, method_colors):
    if label.endswith("(hdg)"):
        base_name = label.replace("(hdg)", "").strip()
        linestyle = "-"
        marker = 'o'
    elif label.endswith("(dg)"):
        base_name = label.replace("(dg)", "").strip()
        linestyle = "--"
        marker = '^'
    else:
        base_name = label.strip()
        linestyle = ":"
        marker = '.'
    color = method_colors.get(base_name, "black")
    return {
        "label": label,
        "linestyle": linestyle,
        "marker": marker,
        "markersize": 8,
        "markevery": 50,
        "color": color
    }
[9]:
# Run simulation(s).
routines = [implicit_euler_hdg, bdf2_hdg, sdirk33_hdg]
# Figure 1: logarithmic plot of the relative error in the L2-norm: density.
fig1, ax1 = setup_fig("Time", r"$\| \rho_e - \rho_h \|$", r"Time Evolution of Relative Density L2-norm")
# Figure 2: logarithmic plot of the relative error in the L2-norm: magnitude of momentum.
fig2, ax2 = setup_fig("Time", r"$\| \rho u_e - \rho u_h \|$", r"Time Evolution of Relative Momentum L2-norm")
# Run each simulation.
for routine in routines:
    print(f"Running {routine.label}...")
    data = routine(draw_solution=False)
    style = get_plot_style(routine.label, method_colors)
    ax1.semilogy(data[:, 0], data[:, 1], **style)
    ax2.semilogy(data[:, 0], data[:, 2], **style)
    uh = cfg.get_solution_fields('rho_u')
    ue = AnalyticSolution(cfg, cfg.time.timer.t)
    isentropic_deviation = (uh.p/ue.p) * (ue.rho/uh.rho)**gamma - 1.0
    cfg.io.draw({"Deviation[Isentropy]": isentropic_deviation}, min=-0.0001, max=0.0001)
# Final touches.
ax1.legend(loc="upper left", frameon=False)
ax2.legend(loc="upper left", frameon=False)
fig1.tight_layout()
fig2.tight_layout()
plt.show()
Running implicit_euler(hdg)...
dream.time      (INFO) | conservative_hdg implicit_euler | it: 2 | error: 4.068907e-15 | t: 5.00
Running bdf2(hdg)...
dream.time      (INFO) | conservative_hdg bdf2 | it: 2 | error: 9.312217e-15 | t: 5.00
Running sdirk33(hdg)...
dream.time      (INFO) | conservative_hdg sdirk33 | it: 1 | error: 8.601520e-09 | stage: 3 | t: 5.00
 
