"""Simulation classes and helpers for evaluating kinetic models against data.
.. moduleauthor:: Kyle Niemeyer <kyle.niemeyer@gmail.com>
"""
# Standard libraries
import warnings
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
# Related modules
import cantera as ct
import numpy as np
import tables
from .detect_peaks import detect_peaks
# Local imports
from .utils import units
[docs]
class VolumeProfile(object):
"""Set the velocity of reactor moving wall via specified volume profile.
The initialization and calling of this class are handled by the
`Func1
<http://cantera.github.io/docs/sphinx/html/cython/zerodim.html#cantera.Func1>`_
interface of Cantera.
Based on ``VolumeProfile`` implemented in Bryan W. Weber's
`CanSen <http://bryanwweber.github.io/CanSen/>`
"""
def __init__(self, volume_history):
"""Set the initial values of the arrays from the input keywords.
The time and volume are read from the input file and stored in an
``VolumeHistory`` object. The velocity is calculated by
assuming a unit area and using central differences. This function is
only called once when the class is initialized at the beginning of a
problem so it is efficient.
Parameters
----------
volume_history : pyked.chemked.TimeHistory
Time and volume history for the case
"""
# The time and volume are each stored as a ``np.array`` in the
# properties dictionary. The volume is normalized by the first volume
# element so that a unit area can be used to calculate the velocity.
self.times = volume_history.time.magnitude
volumes = volume_history.quantity.magnitude / volume_history.quantity.magnitude[0]
# The velocity is calculated by the second-order central differences.
self.velocity = np.gradient(volumes, self.times, edge_order=2)
def __call__(self, time):
"""Return (interpolated) velocity when called during a time step.
Parameters
----------
time : float
Current simulation time, in seconds
Returns
-------
float
Wall velocity, in meters per second
"""
return np.interp(time, self.times, self.velocity, left=0.0, right=0.0)
[docs]
class PressureRiseProfile(VolumeProfile):
r"""Set the velocity of reactor moving wall via specified pressure rise.
The initialization and calling of this class are handled by the
`Func1 <http://cantera.github.io/docs/sphinx/html/cython/zerodim.html#cantera.Func1>`_
interface of Cantera.
The approach used here is based on that discussed by Chaos and Dryer,
"Chemical-kinetic modeling of ignition delay: Considerations in
interpreting shock tube data", *Int J Chem Kinet* 2010 42:143-150,
`doi:10.1002/kin.20471 <http://dx.doi.org/10.1002/kin.20471`.
A time-dependent polytropic state change is emulated by determining volume
as a function of time, via a constant linear pressure rise :math:`A`
(given as a percentage of the initial pressure):
.. math::
\frac{dv}{dt} &= -\frac{1}{\gamma} \frac{v(t)}{P(t)} \frac{dP}{dt} \\
v(t) &= \frac{1}{\rho} \left[ \frac{P(t)}{P_0} \right]^{-1 / \gamma}
\frac{dP}{dt} &= A P_0 \\
\therefore P(t) &= P_0 (A t + 1)
\frac{dv}{dt} = -A \frac{1}{\rho \gamma} (A t + 1)^{-1 / \gamma}
The expression for :math:`\frac{dv}{dt}` can then be used directly for
the ``Wall`` velocity.
"""
def __init__(
self, mech_filename, initial_temp, initial_pres, reactants, pressure_rise, time_end
):
"""Set the initial values of properties needed for velocity.
Parameters
----------
mech_filename : str or pathlib.Path
Cantera-format mechanism file
initial_temp : float
Initial temperature, in K
initial_pres : float
Initial pressure, in Pa
reactants : str
Reactants composition in mole fraction
pressure_rise : float
Pressure rise rate, in s^-1
time_end : float
End time of simulation, in s
"""
self.times, volumes = HomogeneousReactorSimulation.create_volume_history(
mech_filename, initial_temp, initial_pres, reactants, pressure_rise, time_end
)
# Calculate velocity by second-order finite difference
self.velocity = np.gradient(volumes, self.times, edge_order=2)
[docs]
class BaseSimulation(ABC):
"""Abstract base class for a single simulation case of a kinetic model.
Subclasses implement a specific simulation type, e.g. a homogeneous reactor
for autoignition delay or a one-dimensional flame for laminar burning
velocity.
Parameters
----------
kind : str
Kind of experiment (e.g., 'ignition delay')
apparatus : str
Type of apparatus (e.g., 'shock tube')
meta : dict
Metadata for this case
properties : pyked.chemked.DataPoint
Set of properties for this case
"""
def __init__(self, kind, apparatus, meta, properties):
"""Initialize simulation case."""
self.kind = kind
self.apparatus = apparatus
self.meta = meta
self.properties = properties
def _setup_gas(self, model_file, species_key):
"""Create the Cantera gas object and set its initial state.
Handles the model-agnostic part of case setup: loading the model,
converting temperature and pressure to Cantera units, mapping reactant
names via ``species_key``, and setting the initial composition.
Parameters
----------
model_file : str or pathlib.Path
Filename for Cantera-format model
species_key : dict
Dictionary with species names for ``model_file``
"""
self.gas = ct.Solution(model_file)
# Initial temperature needed in Kelvin for Cantera
self.properties.temperature.ito("kelvin")
# Initial pressure needed in Pa for Cantera
self.properties.pressure.ito("pascal")
# convert reactant names to those needed for model
reactants = [
species_key[self.properties.composition[spec].species_name]
+ ":"
+ str(self.properties.composition[spec].amount.magnitude)
for spec in self.properties.composition
]
reactants = ",".join(reactants)
# need to extract values from Quantity or Measurement object
if hasattr(self.properties.temperature, "value"):
temp = self.properties.temperature.value.magnitude
elif hasattr(self.properties.temperature, "nominal_value"):
temp = self.properties.temperature.nominal_value
else:
temp = self.properties.temperature.magnitude
if hasattr(self.properties.pressure, "value"):
pres = self.properties.pressure.value.magnitude
elif hasattr(self.properties.pressure, "nominal_value"):
pres = self.properties.pressure.nominal_value
else:
pres = self.properties.pressure.magnitude
# Reactants given in format for Cantera
if self.properties.composition_type in ["mole fraction", "mole percent"]:
self.gas.TPX = temp, pres, reactants
elif self.properties.composition_type == "mass fraction":
self.gas.TPY = temp, pres, reactants
else:
raise ValueError(
"composition type not supported: " + str(self.properties.composition_type)
)
[docs]
def clean(self):
"""Remove the intermediate results data file, if it exists."""
save_file = self.meta.get("save-file")
if save_file is not None:
Path(save_file).unlink(missing_ok=True)
[docs]
@abstractmethod
def setup_case(self, model_file, species_key, path=""):
"""Set up the simulation case to be run.
Parameters
----------
model_file : str or pathlib.Path
Filename for Cantera-format model
species_key : dict
Dictionary with species names for ``model_file``
path : str or pathlib.Path, optional
Directory in which to save any results data file. The default
(``""``) uses the current working directory.
"""
[docs]
@abstractmethod
def run_case(self, restart=False):
"""Run the simulation case set up by ``setup_case``.
Parameters
----------
restart : bool, optional
If ``True``, skip the case when its results file already exists
(default: ``False``).
"""
[docs]
@abstractmethod
def process_results(self):
"""Process results to obtain the simulated metric."""
[docs]
class HomogeneousReactorSimulation(BaseSimulation):
"""Homogeneous (0-D) reactor simulation for autoignition delay.
Models shock tube and rapid compression machine ignition-delay experiments
using a Cantera ``IdealGasReactor``.
"""
[docs]
@staticmethod
def sample_rising_pressure(time_end, init_pres, freq, pressure_rise_rate):
"""Samples pressure for particular frequency assuming linear rise.
Parameters
----------
time_end : float
End time of simulation, in seconds
init_pres : float
Initial pressure, in Pa
freq : float
Frequency of sampling, in Hz
pressure_rise_rate : float
Pressure rise rate, in s^-1
Returns
-------
tuple of numpy.ndarray
Tuple of times and sampled pressures
"""
times = np.arange(0.0, time_end + (1.0 / freq), (1.0 / freq))
pressures = init_pres * (pressure_rise_rate * times + 1.0)
return times, pressures
[docs]
@staticmethod
def create_volume_history(mech, temp, pres, reactants, pres_rise, time_end):
"""Construct a volume profile based on initial conditions and pressure rise.
Parameters
----------
mech : str or pathlib.Path
Cantera-format mechanism file (``*.yaml``)
temp : float
Initial temperature, in K
pres : float
Initial pressure, in Pa
reactants : str
Reactants composition in mole fraction
pres_rise : float
Pressure rise rate, in s^-1
time_end : float
End time of simulation, in s
Returns
-------
tuple of numpy.ndarray
Times and computed volumes
"""
gas = ct.Solution(mech)
gas.TPX = temp, pres, reactants
initial_entropy = gas.entropy_mass
initial_density = gas.density
# Sample pressure at 20 kHz
freq = 2.0e4
times, pressures = HomogeneousReactorSimulation.sample_rising_pressure(
time_end, pres, freq, pres_rise
)
# Calculate volume profile based on pressure
volumes = np.zeros((len(pressures)))
for i, p in enumerate(pressures):
gas.SP = initial_entropy, p
volumes[i] = initial_density / gas.density
return times, volumes
[docs]
@staticmethod
def get_ignition_delay(time, target, target_name, ignition_type):
"""Identify ignition delay based on time, target, and type of detection.
Parameters
----------
time : numpy.ndarray
Times in s
target : numpy.ndarray
Values of target quantity of interest (e.g., temperature, pressure, species amount)
target_name : str
Name of target quantity of interest (e.g., 'temperature', 'OH')
ignition_type : str
'max', 'd/dt max', '1/2 max', or 'd/dt max extrapolated'
Returns
-------
numpy.ndarray
One or more calculated ignition delay times, in s
"""
no_ignition_delay = np.array([0.0])
if ignition_type == "max":
# Get indices of peaks
peak_inds = detect_peaks(target, edge=None, mph=1.0e-9 * np.max(target))
if peak_inds.size == 0:
return no_ignition_delay
else:
# ignition delay is the time of the largest peak
max_ind = peak_inds[np.argmax(target[peak_inds])]
ign_delays = np.array([time[max_ind]])
elif ignition_type == "d/dt max":
target = np.gradient(target, time, edge_order=2)
# Get indices of peaks. Set a minimum peak height of 1e-7% of the
# maximum value to avoid noise peaks.
peak_inds = detect_peaks(target, edge=None, mph=1.0e-9 * np.max(target))
if peak_inds.size == 0:
return no_ignition_delay
else:
# ignition delay is the time of the largest-derivative peak
max_ind = peak_inds[np.argmax(target[peak_inds])]
ign_delays = np.array([time[max_ind]])
elif ignition_type == "1/2 max":
# maximum value, and associated index
max_val = np.max(target)
peak_inds = detect_peaks(target, edge=None, mph=1.0e-9 * np.max(target))
if peak_inds.size == 0:
return no_ignition_delay
else:
max_ind = peak_inds[np.argmax(target[peak_inds])]
# TODO: interpolate for actual half-max value
# Find index associated with the 1/2 max value, but only consider
# points before the peak
half_idx = (np.abs(target[0:max_ind] - 0.5 * max_val)).argmin()
ign_delays = np.array([time[half_idx]])
elif ignition_type == "d/dt max extrapolated":
# First need to evaluate derivative of the target
target_derivative = np.gradient(target, time, edge_order=2)
max_derivative = np.max(target_derivative)
if not np.isfinite(max_derivative) or max_derivative <= 0.0:
return no_ignition_delay
# Get indices of peaks, and index of largest peak, which corresponds to
# the point of maximum derivative
peak_inds = detect_peaks(target_derivative, edge=None, mph=1.0e-9 * max_derivative)
if peak_inds.size == 0:
return no_ignition_delay
else:
max_ind = peak_inds[np.argmax(target_derivative[peak_inds])]
if target_derivative[max_ind] <= 0.0:
return no_ignition_delay
# use slope to extrapolate to intercept with baseline value (0 by default)
ign_delays = np.array(
[time[max_ind] - (target[max_ind] / target_derivative[max_ind])]
)
# TODO: handle target with nonzero baseline?
else:
warnings.warn(
"Unable to process ignition type "
+ ignition_type
+ ", setting result to 0 and continuing",
RuntimeWarning,
)
return np.array([0.0])
# something has gone wrong if there is still no peak. This shouldn't be necessary.
if ign_delays.size == 0:
filename = "target-data-" + str(datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) + ".out"
warnings.warn(
"No peak found, dumping target data to " + filename + " and continuing",
RuntimeWarning,
)
np.savetxt(filename, np.c_[time, target], header=("time, target (" + target_name + ")"))
return np.array([0.0])
return ign_delays
[docs]
def setup_case(self, model_file, species_key, path=""):
"""Sets up the simulation case to be run.
Parameters
----------
model_file : str or pathlib.Path
Filename for Cantera-format model
species_key : dict
Dictionary with species names for ``model_file``
path : str or pathlib.Path, optional
Directory in which to save the results data file. The default
(``""``) writes to the current working directory.
"""
# Convert ignition delay to seconds
self.properties.ignition_delay.ito("second")
# Set end time of simulation to 100 times the experimental ignition delay
if hasattr(self.properties.ignition_delay, "value"):
self.time_end = 100.0 * self.properties.ignition_delay.value.magnitude
else:
self.time_end = 100.0 * self.properties.ignition_delay.magnitude
# Set up the gas object and its initial thermochemical state
self._setup_gas(model_file, species_key)
# Create non-interacting ``Reservoir`` on other side of ``Wall``
env = ct.Reservoir(ct.Solution("air.yaml"), clone=True)
# All reactors are ``IdealGasReactor`` objects
self.reac = ct.IdealGasReactor(self.gas, clone=True)
if self.apparatus == "shock tube" and self.properties.pressure_rise is None:
# Shock tube modeled by constant UV
self.wall = ct.Wall(self.reac, env, A=1.0, velocity=0)
elif self.apparatus == "shock tube" and self.properties.pressure_rise is not None:
# Shock tube modeled by constant UV with isentropic compression
# Need to convert pressure rise units to seconds
self.properties.pressure_rise.ito("1 / second")
if hasattr(self.properties.pressure_rise, "value"):
pres_rise = self.properties.pressure_rise.value.magnitude
else:
pres_rise = self.properties.pressure_rise.magnitude
self.wall = ct.Wall(
self.reac,
env,
A=1.0,
velocity=PressureRiseProfile(
model_file, self.gas.T, self.gas.P, self.gas.X, pres_rise, self.time_end
),
)
elif (
self.apparatus == "rapid compression machine" and self.properties.volume_history is None
):
# Rapid compression machine modeled by constant UV
self.wall = ct.Wall(self.reac, env, A=1.0, velocity=0)
elif (
self.apparatus == "rapid compression machine"
and self.properties.volume_history is not None
):
# Rapid compression machine modeled with volume-time history
# First convert time units if necessary
self.properties.volume_history.time.ito("second")
self.wall = ct.Wall(
self.reac, env, A=1.0, velocity=VolumeProfile(self.properties.volume_history)
)
# Number of solution variables is number of species + mass,
# volume, temperature
self.n_vars = self.reac.phase.n_species + 3
# Create ``ReactorNet`` newtork
self.reac_net = ct.ReactorNet([self.reac])
# Set maximum time step based on volume-time history, if present
if self.properties.volume_history is not None:
# Minimum difference between volume profile times
min_time = np.min(np.diff(self.properties.volume_history.time.magnitude))
self.reac_net.max_time_step = min_time
# Check if species ignition target, that species is present.
if self.properties.ignition_type["target"] not in ["pressure", "temperature"]:
# Other targets are species
spec = self.properties.ignition_type["target"]
# Try finding species in upper- and lower-case
try_list = [spec, spec.lower(), spec.upper()]
# If excited radical, may need to fall back to nonexcited species
if spec[-1] == "*":
try_list += [spec[:-1], spec[:-1].lower(), spec[:-1].upper()]
ind = None
for sp in try_list:
try:
ind = self.gas.species_index(sp)
break
except ValueError:
pass
# store index of target species
if ind:
self.properties.ignition_target = ind
self.properties.ignition_type = self.properties.ignition_type["type"]
else:
warnings.warn(
spec + " not found in model; falling back on pressure.", RuntimeWarning
)
self.properties.ignition_target = "pressure"
self.properties.ignition_type = "d/dt max"
else:
self.properties.ignition_target = self.properties.ignition_type["target"]
self.properties.ignition_type = self.properties.ignition_type["type"]
# Set file for later data file
file_path = Path(path) / f"{self.meta['id']}.h5"
self.meta["save-file"] = file_path
[docs]
def run_case(self, restart=False):
"""Run simulation case set up by ``setup_case``.
Parameters
----------
restart : bool, optional
If ``True``, skip the case when its results file already exists
(default: ``False``).
"""
if restart and Path(self.meta["save-file"]).is_file():
print("Skipped existing case ", self.meta["id"])
return
# Save simulation results in hdf5 table format.
table_def = {
"time": tables.Float64Col(pos=0),
"temperature": tables.Float64Col(pos=1),
"pressure": tables.Float64Col(pos=2),
"volume": tables.Float64Col(pos=3),
"mass_fractions": tables.Float64Col(shape=(self.reac.phase.n_species), pos=4),
}
with tables.open_file(self.meta["save-file"], mode="w", title=self.meta["id"]) as h5file:
table = h5file.create_table(where=h5file.root, name="simulation", description=table_def)
# Row instance to save timestep information to
timestep = table.row
# Save initial conditions
timestep["time"] = self.reac_net.time
timestep["temperature"] = self.reac.T
timestep["pressure"] = self.reac.phase.P
timestep["volume"] = self.reac.volume
timestep["mass_fractions"] = self.reac.Y
# Add ``timestep`` to table
timestep.append()
# Main time integration loop; continue integration while time of
# the ``ReactorNet`` is less than specified end time.
while self.reac_net.time < self.time_end:
self.reac_net.step()
# Save new timestep information
timestep["time"] = self.reac_net.time
timestep["temperature"] = self.reac.T
timestep["pressure"] = self.reac.phase.P
timestep["volume"] = self.reac.volume
timestep["mass_fractions"] = self.reac.Y
# Add ``timestep`` to table
timestep.append()
# Write ``table`` to disk
table.flush()
print("Done with case ", self.meta["id"])
[docs]
def process_results(self):
"""Process integration results to obtain ignition delay."""
# Load saved integration results
with tables.open_file(self.meta["save-file"], "r") as h5file:
# Load Table with Group name simulation
table = h5file.root.simulation
time = table.col("time")
if self.properties.ignition_target == "pressure":
target = table.col("pressure")
elif self.properties.ignition_target == "temperature":
target = table.col("temperature")
else:
target = table.col("mass_fractions")[:, self.properties.ignition_target]
# store initial and maximum temperatures as a gate for whether the case ignited
init_temperature = table.col("temperature")[0]
max_temperature = np.max(table.col("temperature"))
# add units to time
time = time * units.second
# Will need to subtract compression time for RCM
time_comp = 0.0
if hasattr(self.properties.rcm_data, "compression_time"):
if hasattr(self.properties.rcm_data.compression_time, "value"):
time_comp = self.properties.rcm_data.compression_time.value
else:
time_comp = self.properties.rcm_data.compression_time
# First use basic check for ignition based on temperature increase of at least 50 K
if max_temperature >= init_temperature + 50.0:
ignition_delays = self.get_ignition_delay(
time.magnitude,
target,
self.properties.ignition_target,
self.properties.ignition_type,
)
self.meta["simulated-ignition-delay"] = (ignition_delays[0] - time_comp) * units.second
else:
warnings.warn(
"No ignition for case " + self.meta["id"] + ", setting value to 0.0 and continuing",
RuntimeWarning,
)
self.meta["simulated-ignition-delay"] = 0.0 * units.second
# TODO: detect two-stage ignition.
self.meta["simulated-first-stage-delay"] = np.nan * units.second
[docs]
class FlameSimulation(BaseSimulation):
"""One-dimensional freely propagating laminar flame simulation.
Placeholder for laminar burning velocity measurements. The corresponding
PyKED schema is still in development (see PyKED PR #141), so this simulation
type is not yet implemented.
"""
_NOT_IMPLEMENTED = (
"Laminar flame simulations are not yet supported; the PyKED laminar "
"burning velocity schema is still in development."
)
[docs]
def setup_case(self, model_file, species_key, path=""):
raise NotImplementedError(self._NOT_IMPLEMENTED)
[docs]
def run_case(self, restart=False):
raise NotImplementedError(self._NOT_IMPLEMENTED)
[docs]
def process_results(self):
raise NotImplementedError(self._NOT_IMPLEMENTED)
# Registry mapping experiment/measurement type to the Simulation subclass that
# models it. New simulation types (e.g. laminar flame speed, speciation) can be
# added here as their PyKED schemas are finalized.
_SIMULATION_TYPES = {
"ignition delay": HomogeneousReactorSimulation,
# future: 'laminar burning velocity': FlameSimulation,
}
[docs]
def create_simulation(kind, apparatus, meta, properties):
"""Construct the appropriate :class:`BaseSimulation` subclass for a case.
Parameters
----------
kind : str
Experiment/measurement type (e.g., 'ignition delay')
apparatus : str
Apparatus kind (e.g., 'shock tube')
meta : dict
Metadata for this case
properties : pyked.chemked.DataPoint
Object with full set of experimental properties for this case
Returns
-------
BaseSimulation
Simulation case of the appropriate subclass
"""
try:
simulation_class = _SIMULATION_TYPES[kind]
except KeyError:
raise NotImplementedError(
"Simulations for experiment type '{}' are not yet supported.".format(kind)
)
return simulation_class(kind, apparatus, meta, properties)