cantera/interfaces/cython/cantera/onedim.pyx
Ray Speth fa1d9e1ca9 [Cython] Use scipy when necessary to support older Python versions
Fixes an incompatibility with Python 2.6 and 3.1. Scipy is not
required when using Python 2.7 or 3.2+.
2013-07-23 15:30:51 +00:00

1291 lines
43 KiB
Cython

import csv
import interrupts
try:
# Python 2.7 or 3.2+
from math import erf
except ImportError:
from scipy.special import erf
cdef class Domain1D:
cdef CxxDomain1D* domain
def __cinit__(self, *args, **kwargs):
self.domain = NULL
def __init__(self, *args, name=None, **kwargs):
if self.domain is NULL:
raise TypeError("Can't instantiate abstract class Domain1D.")
if name is not None:
self.name = name
property index:
"""
Index of this domain in a stack. Returns -1 if this domain is not part
of a stack.
"""
def __get__(self):
return self.domain.domainIndex()
property n_components:
"""Number of solution components at each grid point."""
def __get__(self):
return self.domain.nComponents()
property n_points:
"""Number of grid points belonging to this domain."""
def __get__(self):
return self.domain.nPoints()
def component_name(self, int n):
"""Name of the nth component."""
return pystr(self.domain.componentName(n))
property component_names:
"""List of the names of all components of this domain."""
def __get__(self):
return [self.component_name(n) for n in range(self.n_components)]
def component_index(self, str name):
"""Index of the component with name 'name'"""
return self.domain.componentIndex(stringify(name))
def set_bounds(self, *, default=None, Y=None, **kwargs):
"""
Set the lower and upper bounds on the solution.
The argument list should consist of keyword/value pairs, with
component names as keywords and (lower_bound, upper_bound) tuples as
the values. The keyword *default* may be used to specify default
bounds for all unspecified components. The keyword *Y* can be used to
stand for all species mass fractions in flow domains.
>>> d.set_bounds(default=(0, 1), Y=(-1.0e-5, 2.0))
"""
if default is not None:
for n in range(self.n_components):
self.domain.setBounds(n, default[0], default[1])
if Y is not None:
for n in range(4, self.n_components):
self.domain.setBounds(n, Y[0], Y[1])
for name,(lower,upper) in kwargs.items():
self.domain.setBounds(self.component_name(name), lower, upper)
def set_steady_tolerances(self, *, default=None, Y=None, **kwargs):
"""
Set the error tolerances for the steady-state problem.
The argument list should consist of keyword/value pairs, with
component names as keywords and (rtol, atol) tuples as the values.
The keyword *default* may be used to specify default bounds for all
unspecified components. The keyword *Y* can be used to stand for all
species mass fractions in flow domains.
"""
if default is not None:
self.domain.setSteadyTolerances(default[0], default[1])
if Y is not None:
for n in range(4, self.n_components):
self.domain.setSteadyTolerances(Y[0], Y[1], n)
for name,(lower,upper) in kwargs.items():
self.domain.setSteadyTolerances(lower, upper, self.component_name(name))
def set_transient_tolerances(self, *, default=None, Y=None, **kwargs):
"""
Set the error tolerances for the steady-state problem.
The argument list should consist of keyword/value pairs, with
component names as keywords and (rtol, atol) tuples as the values.
The keyword *default* may be used to specify default bounds for all
unspecified components. The keyword *Y* can be used to stand for all
species mass fractions in flow domains.
"""
if default is not None:
self.domain.setTransientTolerances(default[0], default[1])
if Y is not None:
for n in range(4, self.n_components):
self.domain.setTransientTolerances(Y[0], Y[1], n)
for name,(lower,upper) in kwargs.items():
self.domain.setTransientTolerances(lower, upper, self.component_name(name))
def bounds(self, component):
"""
Return the (lower, upper) bounds for a solution component.
>>> d.bounds('T')
(200.0, 5000.0)
"""
n = self.component_index(component)
return self.domain.lowerBound(n), self.domain.upperBound(n)
def tolerances(self, component):
"""
Return the (relative, absolute) error tolerances for a solution
component.
>>> rtol, atol = d.tolerances('u')
"""
k = self.component_index(component)
return self.domain.rtol(k), self.domain.atol(k)
property grid:
""" The grid for this domain """
def __get__(self):
cdef np.ndarray[np.double_t, ndim=1] grid = np.empty(self.n_points)
cdef int i
for i in range(self.n_points):
grid[i] = self.domain.grid(i)
return grid
def __set__(self, grid):
cdef np.ndarray[np.double_t, ndim=1] data = \
np.ascontiguousarray(grid, dtype=np.double)
self.domain.setupGrid(len(data), &data[0])
property name:
""" The name / id of this domain """
def __get__(self):
return pystr(self.domain.id())
def __set__(self, name):
self.domain.setID(stringify(name))
property description:
""" A description of this domain """
def __get__(self):
return pystr(self.domain.desc())
def __set__(self, desc):
self.domain.setDesc(stringify(desc))
cdef class Boundary1D(Domain1D):
"""
Base class for boundary domains.
:param phase:
The (gas) phase corresponding to the adjacent flow domain
"""
cdef CxxBdry1D* boundary
cdef _SolutionBase phase
def __cinit__(self, *args, **kwargs):
self.boundary = NULL
def __init__(self, *args, _SolutionBase phase, **kwargs):
if self.boundary is NULL:
raise TypeError("Can't instantiate abstract class Boundary1D.")
self.domain = <CxxDomain1D*>(self.boundary)
self.phase = phase
Domain1D.__init__(self, *args, **kwargs)
property T:
""" The temperature [K] at this boundary. """
def __get__(self):
return self.boundary.temperature()
def __set__(self, T):
self.boundary.setTemperature(T)
property mdot:
""" The mass flow rate per unit area [kg/m^2] """
def __get__(self):
return self.boundary.mdot()
def __set__(self, mdot):
self.boundary.setMdot(mdot)
property X:
"""
Species mole fractions at this boundary. May be set as either a string
or as an array.
"""
def __get__(self):
self.phase.TPY = self.phase.T, self.phase.P, self.Y
return self.phase.X
def __set__(self, X):
cdef np.ndarray[np.double_t, ndim=1] data
if isinstance(X, (str, unicode)):
self.boundary.setMoleFractions(stringify(X))
else:
data = np.ascontiguousarray(X, dtype=np.double)
self.boundary.setMoleFractions(&data[0])
property Y:
"""
Species mass fractions at this boundary. May be set as either a string
or as an array.
"""
def __get__(self):
cdef int nsp = self.boundary.nSpecies()
cdef np.ndarray[np.double_t, ndim=1] Y = np.empty(nsp)
cdef int k
for k in range(nsp):
Y[k] = self.boundary.massFraction(k)
return Y
def __set__(self, Y):
self.phase.TPY = self.phase.T, self.phase.P, Y
self.X = self.phase.X
cdef class Inlet1D(Boundary1D):
"""
A one-dimensional inlet. Note that an inlet can only be a terminal
domain - it must be either the leftmost or rightmost domain in a
stack.
"""
cdef CxxInlet1D* inlet
def __cinit__(self, *args, **kwargs):
self.inlet = new CxxInlet1D()
self.boundary = <CxxBdry1D*>(self.inlet)
def __dealloc__(self):
del self.inlet
property spread_rate:
def __get__(self):
return self.inlet.spreadRate()
def __set__(self, s):
self.inlet.setSpreadRate(s)
cdef class Outlet1D(Boundary1D):
"""
A one-dimensional outlet. An outlet imposes a zero-gradient boundary
condition on the flow.
"""
cdef CxxOutlet1D* outlet
def __cinit__(self, *args, **kwargs):
self.outlet = new CxxOutlet1D()
self.boundary = <CxxBdry1D*>(self.outlet)
def __dealloc__(self):
del self.outlet
cdef class OutletReservoir1D(Boundary1D):
"""
A one-dimensional outlet into a reservoir.
"""
cdef CxxOutletRes1D* outlet
def __cinit__(self, *args, **kwargs):
self.outlet = new CxxOutletRes1D()
self.boundary = <CxxBdry1D*>(self.outlet)
def __dealloc__(self):
del self.outlet
cdef class SymmetryPlane1D(Boundary1D):
"""A symmetry plane."""
cdef CxxSymm1D* symm
def __cinit__(self, *args, **kwargs):
self.symm = new CxxSymm1D()
self.boundary = <CxxBdry1D*>(self.symm)
def __dealloc__(self):
del self.symm
cdef class Surface1D(Boundary1D):
"""A solid surface."""
cdef CxxSurf1D* surf
def __cinit__(self, *args, **kwargs):
self.surf = new CxxSurf1D()
self.boundary = <CxxBdry1D*>(self.surf)
def __dealloc__(self):
del self.surf
cdef class ReactingSurface1D(Boundary1D):
"""A reacting solid surface."""
cdef CxxReactingSurf1D* surf
def __cinit__(self, *args, **kwargs):
self.surf = new CxxReactingSurf1D()
self.boundary = <CxxBdry1D*>(self.surf)
def __dealloc__(self):
del self.surf
def set_kinetics(self, Kinetics kin):
"""Set the kinetics manager (surface reaction mechanism object)."""
if kin.kinetics.type() not in (kinetics_type_interface,
kinetics_type_edge):
raise TypeError('Kinetics object must be derived from '
'InterfaceKinetics.')
self.surf.setKineticsMgr(<CxxInterfaceKinetics*>kin.kinetics)
property coverage_enabled:
"""Controls whether or not to solve the surface coverage equations."""
def __set__(self, value):
self.surf.enableCoverageEquations(<cbool>value)
cdef class _FlowBase(Domain1D):
""" Base class for 1D flow domains """
cdef CxxStFlow* flow
cdef _SolutionBase gas
def __cinit__(self, *args, **kwargs):
self.flow = NULL
def __init__(self, _SolutionBase thermo, *args, **kwargs):
self.domain = <CxxDomain1D*>(self.flow)
super().__init__(*args, **kwargs)
self.gas = thermo
self.flow.setKinetics(deref(self.gas.kinetics))
self.flow.setTransport(deref(self.gas.transport))
self.P = self.gas.P
self.flow.solveEnergyEqn()
property P:
""" Pressure [Pa] """
def __get__(self):
return self.flow.pressure()
def __set__(self, P):
self.flow.setPressure(P)
def set_transport(self, _SolutionBase phase):
self.gas = phase
self.flow.setTransport(deref(self.gas.transport))
property soret_enabled:
"""
Determines whether or not to include diffusive mass fluxes due to the
Soret effect. Enabling this option works only when using the
multicomponent transport model.
"""
def __get__(self):
return self.flow.withSoret()
def __set__(self, enable):
self.flow.enableSoret(<cbool>enable)
property energy_enabled:
""" Determines whether or not to solve the energy equation."""
def __get__(self):
return self.flow.doEnergy(0)
def __set__(self, enable):
if enable:
self.flow.solveEnergyEqn()
else:
self.flow.fixTemperature()
def set_fixed_temp_profile(self, pos, T):
"""Set the fixed temperature profile. This profile is used
whenever the energy equation is disabled.
:param pos:
arrray of relative positions from 0 to 1
:param temp:
array of temperature values
>>> d.set_fixed_temp_profile(array([0.0, 0.5, 1.0]),
... array([500.0, 1500.0, 2000.0])
"""
cdef vector[double] x, y
for p in pos:
x.push_back(p)
for t in T:
y.push_back(t)
self.flow.setFixedTempProfile(x, y)
def __dealloc__(self):
del self.flow
cdef CxxIdealGasPhase* getIdealGasPhase(ThermoPhase phase) except *:
if phase.thermo.eosType() != thermo_type_ideal_gas:
raise TypeError('ThermoPhase object is not an IdealGasPhase')
return <CxxIdealGasPhase*>(phase.thermo)
cdef class FreeFlow(_FlowBase):
def __cinit__(self, _SolutionBase thermo, *args, **kwargs):
gas = getIdealGasPhase(thermo)
self.flow = <CxxStFlow*>(new CxxFreeFlame(gas, thermo.n_species, 2))
cdef class AxisymmetricStagnationFlow(_FlowBase):
"""
An axisymmetric flow domain.
In an axisymmetric flow domain, the equations solved are the similarity
equations for the flow in a finite-height gap of infinite radial extent.
The solution variables are:
*u*
axial velocity
*V*
radial velocity divided by radius
*T*
temperature
*lambda*
(1/r)(dP/dr)
*Y_k*
species mass fractions
It may be shown that if the boundary conditions on these variables are
independent of radius, then a similarity solution to the exact governing
equations exists in which these variables are all independent of radius.
This solution holds only in in low-Mach-number limit, in which case
(dP/dz) = 0, and lambda is a constant. (Lambda is treated as a spatially-
varying solution variable for numerical reasons, but in the final solution
it is always independent of z.) As implemented here, the governing
equations assume an ideal gas mixture. Arbitrary chemistry is allowed, as
well as arbitrary variation of the transport properties.
"""
def __cinit__(self, _SolutionBase thermo, *args, **kwargs):
gas = getIdealGasPhase(thermo)
self.flow = <CxxStFlow*>(new CxxAxiStagnFlow(gas, thermo.n_species, 2))
cdef class Sim1D:
"""
Class Sim1D is a container for one-dimensional domains. It also holds the
multi-domain solution vector, and controls the process of finding the
solution.
Domains are ordered left-to-right, with domain number 0 at the left.
"""
cdef CxxSim1D* sim
cdef readonly object domains
cdef object _initialized
cdef Func1 interrupt
def __cinit__(self, *args, **kwargs):
self.sim = NULL
def __init__(self, domains, *args, **kwargs):
cdef vector[CxxDomain1D*] D
cdef Domain1D d
for d in domains:
D.push_back(d.domain)
self.sim = new CxxSim1D(D)
self.domains = tuple(domains)
self.set_interrupt(interrupts.no_op)
self._initialized = False
def set_interrupt(self, f):
"""
Set an interrupt function to be called each time that OneDim::eval is
called. The signature of *f* is `float f(float)`. The default
interrupt function is used to trap KeyboardInterrupt exceptions so
that `ctrl-c` can be used to break out of the C++ solver loop.
"""
if not isinstance(f, Func1):
f = Func1(f)
self.interrupt = f
self.sim.setInterrupt(self.interrupt.func)
def domain_index(self, dom):
"""
Get the index of a domain, specified either by name or as a Domain1D
object.
"""
if isinstance(dom, Domain1D):
idom = self.domains.index(dom)
elif isinstance(dom, int):
idom = dom
else:
idom = None
for i,d in enumerate(self.domains):
if d.name == dom:
idom = i
dom = d
if idom is None:
raise KeyError('Domain named "{}" not found.'.format(dom))
assert 0 <= idom < len(self.domains)
return idom
def _get_indices(self, dom, comp):
idom = self.domain_index(dom)
dom = self.domains[idom]
if isinstance(comp, (str, unicode)):
kcomp = dom.component_index(comp)
else:
kcomp = comp
assert 0 <= kcomp < dom.n_components
return idom, kcomp
def value(self, domain, component, point):
"""
Solution value at one point
:param domain:
Domain1D object, name, or index
:param component:
component name or index
:param point:
grid point number within *domain* starting with 0 on the left
>>> t = s.value('flow', 'T', 6)
"""
dom, comp = self._get_indices(domain, component)
return self.sim.value(dom, comp, point)
def set_value(self, domain, component, point, value):
"""
Set the value of one component in one domain at one point to 'value'.
:param domain:
Domain1D object, name, or index
:param component:
component name or index
:param point:
grid point number within *domain* starting with 0 on the left
:param value:
numerical value
>>> s.set(d, 3, 5, 6.7)
>>> s.set(1, 0, 5, 6.7)
>>> s.set('flow', 'T', 5, 500)
"""
dom, comp = self._get_indices(domain, component)
self.sim.setValue(dom, comp, point, value)
def work_value(self, domain, component, point):
"""
Internal work array value at one point. After calling eval, this array
contains the values of the residual function.
:param domain:
Domain1D object, name, or index
:param component:
component name or index
:param point:
grid point number in the domain, starting with zero at the left
>>> t = s.value(flow, 'T', 6)
"""
dom, comp = self._get_indices(domain, component)
return self.sim.workValue(dom, comp, point)
def profile(self, domain, component):
"""
Spatial profile of one component in one domain.
:param domain:
Domain1D object, name, or index
:param component:
component name or index
>>> T = s.profile(flow, 'T')
"""
idom, kcomp = self._get_indices(domain, component)
dom = self.domains[idom]
cdef int j
cdef np.ndarray[np.double_t, ndim=1] data = np.empty(dom.n_points)
for j in range(dom.n_points):
data[j] = self.sim.value(idom, kcomp, j)
return data
def set_profile(self, domain, component, positions, values):
"""
Set an initial estimate for a profile of one component in one domain.
:param domain:
Domain1D object, name, or index
:param component:
component name or index
:param positions:
sequence of relative positions, from 0 on the left to 1 on the right
:param values:
sequence of values at the relative positions specified in *positions*
>>> s.set_profile(d, 'T', [0.0, 0.2, 1.0], [400.0, 800.0, 1500.0])
"""
dom, comp = self._get_indices(domain, component)
cdef vector[double] pos_vec, val_vec
for p in positions:
pos_vec.push_back(p)
for v in values:
val_vec.push_back(v)
self.sim.setProfile(dom, comp, pos_vec, val_vec)
def set_flat_profile(self, domain, component, value):
"""Set a flat profile for one component in one domain.
:param domain:
Domain1D object, name, or index
:param component:
component name or index
:param v:
value
>>> s.set_flat_profile(d, 'u', -3.0)
"""
dom, comp = self._get_indices(domain, component)
self.sim.setFlatProfile(dom, comp, value)
def show_solution(self):
""" print the current solution. """
if not self._initialized:
self.set_initial_guess()
self.sim.showSolution()
def set_time_step(self, stepsize, n_steps):
"""Set the sequence of time steps to try when Newton fails.
:param stepsize:
initial time step size [s]
:param n_steps:
sequence of integer step numbers
>>> s.set_time_step(1.0e-5, [1, 2, 5, 10])
"""
cdef vector[int] data
for n in n_steps:
data.push_back(n)
self.sim.setTimeStep(stepsize, data.size(), &data[0])
def set_initial_guess(self):
"""
Set the initial guess for the solution. Derived classes extend this
function to set approximations for the temperature and composition
profiles.
"""
self._get_initial_solution()
self._initialized = True
def _get_initial_solution(self):
"""
Load the initial solution from each domain into the global solution
vector.
"""
self.sim.getInitialSoln()
def solve(self, loglevel=1, refine_grid=True):
"""
Solve the problem.
:param loglevel:
integer flag controlling the amount of diagnostic output. Zero
suppresses all output, and 5 produces very verbose output.
:param refine_grid:
if True, enable grid refinement.
"""
if not self._initialized:
self.set_initial_guess()
self.sim.solve(loglevel, <cbool>refine_grid)
def refine(self, loglevel=1):
"""
Refine the grid, adding points where solution is not adequately
resolved.
"""
self.sim.refine(loglevel)
def set_refine_criteria(self, domain, ratio=10.0, slope=0.8, curve=0.8,
prune=0.05):
"""
Set the criteria used to refine one domain.
:param domain:
domain object, index, or name
:param ratio:
additional points will be added if the ratio of the spacing on
either side of a grid point exceeds this value
:param slope:
maximum difference in value between two adjacent points, scaled by
the maximum difference in the profile (0.0 < slope < 1.0). Adds
points in regions of high slope.
:param curve:
maximum difference in slope between two adjacent intervals, scaled
by the maximum difference in the profile (0.0 < curve < 1.0). Adds
points in regions of high curvature.
:param prune:
if the slope or curve criteria are satisfied to the level of
'prune', the grid point is assumed not to be needed and is removed.
Set prune significantly smaller than 'slope' and 'curve'. Set to
zero to disable pruning the grid.
>>> s.set_refine_criteria(d, ratio=5.0, slope=0.2, curve=0.3, prune=0.03)
"""
idom = self.domain_index(domain)
self.sim.setRefineCriteria(idom, ratio, slope, curve, prune)
def set_grid_min(self, dz, domain=None):
"""
Set the minimum grid spacing on *domain*. If *domain* is None, then
set the grid spacing for all domains.
"""
if domain is None:
idom = -1
else:
idom = self.domain_index(domain)
self.sim.setGridMin(idom, dz)
def set_max_jac_age(self, ss_age, ts_age):
"""
Set the maximum number of times the Jacobian will be used before it
must be re-evaluated.
:param ss_age:
age criterion during steady-state mode
:param ts_age:
age criterion during time-stepping mode
"""
self.sim.setJacAge(ss_age, ts_age)
def set_time_step_factor(self, tfactor):
"""
Set the factor by which the time step will be increased after a
successful step, or decreased after an unsuccessful one.
"""
self.sim.setTimeStepFactor(tfactor)
def set_min_time_step(self, tsmin):
""" Set the minimum time step. """
self.sim.setMinTimeStep(tsmin)
def set_max_time_step(self, tsmax):
""" Set the maximum time step. """
self.sim.setMaxTimeStep(tsmax)
def set_fixed_temperature(self, T):
"""
Set the temperature used to fix the spatial location of a freely
propagating flame.
"""
self.sim.setFixedTemperature(T)
def save(self, filename='soln.xml', name='solution', description='none',
loglevel=1):
"""
Save the solution in XML format.
:param filename:
solution file
:param name:
solution name within the file
:param description:
custom description text
>>> s.save(filename='save.xml', name='energy_off',
... description='solution with energy eqn. disabled')
"""
self.sim.save(stringify(filename), stringify(name),
stringify(description), loglevel)
def restore(self, filename='soln.xml', name='solution', loglevel=2):
"""Set the solution vector to a previously-saved solution.
:param filename:
solution file
:param name:
solution name within the file
:param loglevel:
Amount of logging information to display while restoring,
from 0 (disabled) to 2 (most verbose).
>>> s.restore(filename='save.xml', id='energy_off')
"""
self.sim.restore(stringify(filename), stringify(name), loglevel)
self._initialized = True
def show_stats(self, print_time=True):
"""
Show the statistics for the last solution.
If invoked with no arguments or with a non-zero argument, the timing
statistics will be printed. Otherwise, the timing will not be printed.
"""
self.sim.writeStats(print_time)
def __dealloc__(self):
del self.sim
class FlameBase(Sim1D):
""" Base class for flames with a single flow domain """
def __init__(self, domains, gas, grid=None):
"""
:param gas:
object to use to evaluate all gas properties and reaction rates
:param grid:
array of initial grid points
"""
if grid is None:
grid = np.linspace(0.0, 0.1, 6)
self.flame.grid = grid
super().__init__(domains)
self.gas = gas
self.flame.P = gas.P
def set_refine_criteria(self, ratio=10.0, slope=0.8, curve=0.8, prune=0.0):
super().set_refine_criteria(self.flame, ratio, slope, curve, prune)
def set_profile(self, component, locations, values):
super().set_profile(self.flame, component, locations, values)
@property
def transport_model(self):
return self.gas.transport_model
@transport_model.setter
def transport_model(self, model):
self.gas.transport_model = model
self.flame.set_transport(self.gas)
@property
def energy_enabled(self):
return self.flame.energy_enabled
@energy_enabled.setter
def energy_enabled(self, enable):
self.flame.energy_enabled = enable
@property
def soret_enabled(self):
return self.flame.soret_enabled
@soret_enabled.setter
def soret_enabled(self, enable):
self.flame.soret_enabled = enable
@property
def grid(self):
""" Array of grid point positions along the flame. """
return self.flame.grid
@property
def P(self):
return self.flame.P
@P.setter
def P(self, P):
self.flame.P = P
@property
def T(self):
""" Array containing the temperature [K] at each grid point. """
return self.profile(self.flame, 'T')
@property
def u(self):
"""
Array containing the velocity [m/s] normal to the flame at each point.
"""
return self.profile(self.flame, 'u')
@property
def V(self):
"""
Array containing the tangential velocity gradient [1/s] at each point.
"""
return self.profile(self.flame, 'V')
@property
def L(self):
"""
Array containing the radial pressure gradient (1/r)(dP/dr) [N/m^4] at
each point. Note: This value is named 'lambda' in the C++ code.
"""
return self.profile(self.flame, 'lambda')
def solution(self, component, point=None):
if point is None:
return self.profile(self.flame, component)
else:
return self.value(self.flame, component, point)
def set_gas_state(self, point):
k0 = self.flame.component_index(self.gas.species_name(0))
Y = [self.solution(k, point)
for k in range(k0, k0 + self.gas.n_species)]
self.gas.TPY = self.value(self.flame, 'T', point), self.P, Y
def write_csv(self, filename, species='X', quiet=True):
"""
Write the velocity, temperature, density, and species profiles
to a CSV file.
:param filename:
Output file name
:param species:
Attribute to use obtaining species profiles, e.g. ``X`` for
mole fractions or ``Y`` for mass fractions.
"""
z = self.grid
T = self.T
u = self.u
V = self.V
csvfile = open(filename, 'w')
writer = csv.writer(csvfile)
writer.writerow(['z (m)', 'u (m/s)', 'V (1/s)',
'T (K)', 'rho (kg/m3)'] + self.gas.species_names)
for n in range(self.flame.n_points):
self.set_gas_state(n)
writer.writerow([z[n], u[n], V[n], T[n], self.gas.density] +
list(getattr(self.gas, species)))
csvfile.close()
if not quiet:
print("Solution saved to '{}'.".format(filename))
def _trim(docstring):
"""Remove block indentation from a docstring."""
if not docstring:
return ''
lines = docstring.splitlines()
# Determine minimum indentation (first line doesn't count):
indent = 999
for line in lines[1:]:
stripped = line.lstrip()
if stripped:
indent = min(indent, len(line) - len(stripped))
# Remove indentation (first line is special):
trimmed = [lines[0].strip()]
if indent < 999:
for line in lines[1:]:
trimmed.append(line[indent:].rstrip())
# Return a single string, with trailing and leading blank lines stripped
return '\n'.join(trimmed).strip('\n')
def _array_property(attr, size=None):
"""
Generate a property that retrieves values at each point in the flame. The
'size' argument is the attribute name of the gas object used to set the
leading dimension of the resulting array.
"""
def getter(self):
if size is None:
# 1D array for scalar property
vals = np.empty(self.flame.n_points)
else:
# 2D array
vals = np.empty((getattr(self.gas, size), self.flame.n_points))
for i in range(self.flame.n_points):
self.set_gas_state(i)
vals[...,i] = getattr(self.gas, attr)
return vals
if size is None:
extradoc = "\nReturns an array of length `n_points`."
else:
extradoc = "\nReturns an array of size `%s` x `n_points`." % size
doc = _trim(getattr(Solution, attr).__doc__) + extradoc
return property(getter, doc=doc)
# Add scalar properties to FlameBase
for attr in ['density', 'density_mass', 'density_mole', 'volume_mass',
'volume_mole', 'int_energy_mole', 'int_energy_mass', 'h',
'enthalpy_mole', 'enthalpy_mass', 's', 'entropy_mole',
'entropy_mass', 'g', 'gibbs_mole', 'gibbs_mass', 'cv',
'cv_mole', 'cv_mass', 'cp', 'cp_mole', 'cp_mass',
'isothermal_compressibility', 'thermal_expansion_coeff',
'viscosity', 'thermal_conductivity']:
setattr(FlameBase, attr, _array_property(attr))
FlameBase.volume = _array_property('v') # avoid confusion with velocity gradient 'V'
FlameBase.int_energy = _array_property('u') # avoid collision with velocity 'u'
# Add properties with values for each species
for attr in ['X', 'Y', 'concentrations', 'partial_molar_enthalpies',
'partial_molar_entropies', 'partial_molar_int_energies',
'chemical_potentials', 'electrochemical_potentials', 'partial_molar_cp',
'partial_molar_volumes', 'standard_enthalpies_RT',
'standard_entropies_R', 'standard_int_energies_RT',
'standard_gibbs_RT', 'standard_cp_R', 'creation_rates',
'destruction_rates', 'net_production_rates', 'mix_diff_coeffs',
'mix_diff_coeffs_mass', 'mix_diff_coeffs_mole', 'thermal_diff_coeffs']:
setattr(FlameBase, attr, _array_property(attr, 'n_species'))
# Add properties with values for each reaction
for attr in ['forward_rates_of_progress', 'reverse_rates_of_progress', 'net_rates_of_progress',
'equilibrium_constants', 'forward_rate_constants', 'reverse_rate_constants',
'delta_enthalpy', 'delta_gibbs', 'delta_entropy',
'delta_standard_enthalpy', 'delta_standard_gibbs',
'delta_standard_entropy']:
setattr(FlameBase, attr, _array_property(attr, 'n_reactions'))
class FreeFlame(FlameBase):
"""A freely-propagating flat flame."""
def __init__(self, gas, grid=None):
"""
A domain of type FreeFlow named 'flame' will be created to represent
the flame. The three domains comprising the stack are stored as
``self.inlet``, ``self.flame``, and ``self.outlet``.
"""
self.inlet = Inlet1D(name='reactants', phase=gas)
self.outlet = Outlet1D(name='products', phase=gas)
self.flame = FreeFlow(gas, name='flame')
super().__init__((self.inlet, self.flame, self.outlet), gas, grid)
def set_initial_guess(self):
"""
Set the initial guess for the solution. The adiabatic flame
temperature and equilibrium composition are computed for the inlet gas
composition. The temperature profile rises linearly over 20% of the
domain width to Tad, then is flat. The mass fraction profiles are set
similarly.
"""
super().set_initial_guess()
self.gas.TPY = self.inlet.T, self.P, self.inlet.Y
Y0 = self.inlet.Y
u0 = self.inlet.mdot/self.gas.density
T0 = self.inlet.T
# get adiabatic flame temperature and composition
self.gas.equilibrate('HP')
Teq = self.gas.T
Yeq = self.gas.Y
u1 = self.inlet.mdot/self.gas.density
locs = [0.0, 0.3, 0.5, 1.0]
self.set_profile('u', locs, [u0, u0, u1, u1])
self.set_profile('T', locs, [T0, T0, Teq, Teq])
self.set_fixed_temperature(0.5 * (T0 + Teq))
for n in range(self.gas.n_species):
self.set_profile(self.gas.species_name(n),
locs, [Y0[n], Y0[n], Yeq[n], Yeq[n]])
class BurnerFlame(FlameBase):
"""A burner-stabilized flat flame."""
def __init__(self, gas, grid=None):
"""
:param gas:
`Solution` (using the IdealGas thermodynamic model) used to
evaluate all gas properties and reaction rates.
:param grid:
Array of initial grid points
A domain of class `AxisymmetricStagnationFlow` named ``flame`` will
be created to represent the flame. The three domains comprising the
stack are stored as ``self.burner``, ``self.flame``, and
``self.outlet``.
"""
self.burner = Inlet1D(name='burner', phase=gas)
self.burner.T = gas.T
self.outlet = Outlet1D(name='outlet', phase=gas)
self.flame = AxisymmetricStagnationFlow(gas, name='flame')
super().__init__((self.burner, self.flame, self.outlet), gas, grid)
def set_initial_guess(self):
"""
Set the initial guess for the solution. The adiabatic flame
temperature and equilibrium composition are computed for the burner
gas composition. The temperature profile rises linearly in the first
20% of the flame to Tad, then is flat. The mass fraction profiles are
set similarly.
"""
super().set_initial_guess()
self.gas.TPY = self.burner.T, self.P, self.burner.Y
Y0 = self.burner.Y
u0 = self.burner.mdot/self.gas.density
T0 = self.burner.T
# get adiabatic flame temperature and composition
self.gas.equilibrate('HP')
Teq = self.gas.T
Yeq = self.gas.Y
u1 = self.burner.mdot/self.gas.density
locs = [0.0, 0.2, 1.0]
self.set_profile('u', locs, [u0, u1, u1])
self.set_profile('T', locs, [T0, Teq, Teq])
for n in range(self.gas.n_species):
self.set_profile(self.gas.species_name(n),
locs, [Y0[n], Yeq[n], Yeq[n]])
class CounterflowDiffusionFlame(FlameBase):
""" A counterflow diffusion flame """
def __init__(self, gas, grid=None):
"""
:param gas:
`Solution` (using the IdealGas thermodynamic model) used to
evaluate all gas properties and reaction rates.
:param grid:
Array of initial grid points
A domain of class `AxisymmetricStagnationFlow` named ``flame`` will
be created to represent the flame. The three domains comprising the
stack are stored as ``self.fuel_inlet``, ``self.flame``, and
``self.oxidizer_inlet``.
"""
self.fuel_inlet = Inlet1D(name='fuel_inlet', phase=gas)
self.fuel_inlet.T = gas.T
self.oxidizer_inlet = Inlet1D(name='oxidizer_inlet', phase=gas)
self.oxidizer_inlet.T = gas.T
self.flame = AxisymmetricStagnationFlow(gas, name='flame')
super().__init__((self.fuel_inlet, self.flame, self.oxidizer_inlet),
gas, grid)
def set_initial_guess(self, fuel, oxidizer='O2', stoich=None):
"""
Set the initial guess for the solution. The fuel species must be
specified:
>>> f.set_initial_guess(fuel='CH4')
The oxidizer and corresponding stoichiometry must be specified if it
is not 'O2'. The initial guess is generated by assuming infinitely-
fast chemistry.
"""
super().set_initial_guess()
if stoich is None:
if oxidizer == 'O2':
stoich = 0.0
if 'H' in self.gas.element_names:
stoich += 0.25 * self.gas.n_atoms(fuel, 'H')
if 'C' in self.gas.element_names:
stoich += self.gas.n_atoms(fuel, 'C')
else:
raise Exception('oxidizer/fuel stoichiometric ratio must be '
'specified since the oxidizer is not O2')
kFuel = self.gas.species_index(fuel)
kOx = self.gas.species_index(oxidizer)
s = stoich * self.gas.molecular_weights[kOx] / self.gas.molecular_weights[kFuel]
phi = s * self.fuel_inlet.Y[kFuel] / self.oxidizer_inlet.Y[kOx]
zst = 1.0 / (1.0 + phi)
Yin_f = self.fuel_inlet.Y
Yin_o = self.oxidizer_inlet.Y
Yst = zst * Yin_f + (1.0 - zst) * Yin_o
self.gas.TPY = self.fuel_inlet.T, self.P, Yin_f
mdotf = self.fuel_inlet.mdot
u0f = mdotf / self.gas.density
T0f = self.fuel_inlet.T
self.gas.TPY = self.oxidizer_inlet.T, self.P, Yin_o
mdoto = self.oxidizer_inlet.mdot
u0o = mdoto/self.gas.density
T0o = self.oxidizer_inlet.T
# get adiabatic flame temperature and composition
Tbar = 0.5 * (T0f + T0o)
self.gas.TPY = Tbar, self.P, Yst
self.gas.equilibrate('HP')
Teq = self.gas.T
Yeq = self.gas.Y
# estimate strain rate
zz = self.flame.grid
dz = zz[-1] - zz[0]
a = (u0o + u0f)/dz
f = np.sqrt(a / (2.0 * self.gas.mix_diff_coeffs[kOx]))
x0 = mdotf * dz / (mdotf + mdoto)
nz = len(zz)
Y = np.zeros((nz, self.gas.n_species))
T = np.zeros(nz)
for j in range(nz):
x = zz[j]
zeta = f * (x - x0)
zmix = 0.5 * (1.0 - erf(zeta))
if zmix > zst:
Y[j] = Yeq + (Yin_f - Yeq) * (zmix - zst) / (1.0 - zst)
T[j] = Teq + (T0f - Teq) * (zmix - zst) / (1.0 - zst)
else:
Y[j] = Yin_o + zmix * (Yeq - Yin_o) / zst
T[j] = T0o + (Teq - T0o) * zmix / zst
T[0] = T0f
T[-1] = T0o
zrel = zz/dz
self.set_profile('u', [0.0, 1.0], [u0f, -u0o])
self.set_profile('V', [0.0, x0/dz, 1.0], [0.0, a, 0.0])
self.set_profile('T', zrel, T)
for k,spec in enumerate(self.gas.species_names):
self.set_profile(spec, zrel, Y[:,k])
class ImpingingJet(FlameBase):
"""An axisymmetric flow impinging on a surface at normal incidence."""
def __init__(self, gas, grid=None, surface=None):
"""
:param gas:
`Solution` (using the IdealGas thermodynamic model) used to
evaluate all gas properties and reaction rates.
:param grid:
Array of initial grid points
:param surface:
A Kinetics object used to compute any surface reactions.
A domain of class `AxisymmetricStagnationFlow` named ``flame`` will be
created to represent the flow. The three domains comprising the stack
are stored as ``self.inlet``, ``self.flame``, and ``self.surface``.
"""
self.inlet = Inlet1D(name='inlet', phase=gas)
self.inlet.T = gas.T
self.flame = AxisymmetricStagnationFlow(gas, name='flame')
if surface is None:
self.surface = Surface1D(name='surface', phase=gas)
self.surface.T = gas.T
else:
self.surface = ReactingSurface1D(name='surface', phase=gas)
self.surface.set_kinetics(surface)
self.surface.T = surface.T
super().__init__((self.inlet, self.flame, self.surface),
gas, grid)
def set_initial_guess(self, products='inlet'):
"""
Set the initial guess for the solution. If products = 'equil', then
the equilibrium composition at the adiabatic flame temperature will be
used to form the initial guess. Otherwise the inlet composition will
be used.
"""
super().set_initial_guess()
Y0 = self.inlet.Y
T0 = self.inlet.T
self.gas.TPY = T0, self.flame.P, Y0
u0 = self.inlet.mdot / self.gas.density
if products == 'equil':
self.gas.equilibrate('HP')
Teq = self.gas.T
Yeq = self.gas.Y
locs = np.array([0.0, 0.3, 0.7, 1.0])
self.set_profile('T', locs, [T0, Teq, Teq, self.surface.T])
for k in range(self.gas.n_species):
self.set_profile(self.gas.species_name(k), locs,
[Y0[k], Yeq[k], Yeq[k], Yeq[k]])
else:
locs = np.array([0.0, 1.0])
self.set_profile('T', locs, [T0, self.surface.T])
for k in range(self.gas.n_species):
self.set_profile(self.gas.species_name(k), locs,
[Y0[k], Y0[k]])
locs = np.array([0.0, 1.0])
self.set_profile('u', locs, [u0, 0.0])
self.set_profile('V', locs, [0.0, 0.0])