File structure refactored to clean up API

This commit is contained in:
Nicolas Kruse 2025-06-17 22:55:04 +02:00
parent 5c41d04a70
commit 412827226f
6 changed files with 1116 additions and 1072 deletions

View File

@ -6,7 +6,7 @@ The operating parameters chosen here are not necessary realistic
```python ```python
import gaspype as gp import gaspype as gp
from gaspype import R, F from gaspype.constants import R, F
import numpy as np import numpy as np
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
``` ```

File diff suppressed because it is too large Load Diff

770
src/gaspype/_main.py Normal file
View File

@ -0,0 +1,770 @@
import numpy as np
from numpy.typing import NDArray
from typing import Sequence, Any, TypeVar, Iterator, overload, Callable
from math import log as ln, ceil
from scipy.linalg import null_space
from gaspype._phys_data import atomic_weights, db_reader
import re
import pkgutil
from .constants import R, epsy, p0
_Shape = tuple[int, ...]
NDFloat = np.float64
FloatArray = NDArray[NDFloat]
T = TypeVar('T', 'fluid', 'elements')
_data = pkgutil.get_data(__name__, 'data/therm_data.bin')
assert _data is not None, 'Could not load thermodynamic data'
_species_db = db_reader(_data)
def species(pattern: str = '*', element_names: str | list[str] = [], use_regex: bool = False) -> list[str]:
"""Returns a alphabetically sorted list of all available species
filtered by a pattern if supplied
Args:
pattern: Optional filter for specific molecules
Placeholder characters:
# A number including non written ones: 'C#H#' matches 'CH4';
$ Arbitrary element name;
* Any sequence of characters
element_names:
restrict results to species that contain only the specified elements.
The elements can be supplied as list of strings or as comma separated string.
use_regex: using regular expression for the pattern
Returns:
List of species
"""
if isinstance(element_names, str):
elements = {s.strip() for s in element_names.split(',')}
else:
assert isinstance(element_names, list), 'type of element_names must be list or str'
elements = set(element_names)
for el in elements:
assert el in atomic_weights, f'element {el} unknown'
if not use_regex:
el_pattern = '|'.join([el for el in atomic_weights.keys()])
pattern = pattern.replace('*', '.*')
pattern = pattern.replace('#', '\\d*')
pattern = pattern.replace('$', '(' + el_pattern + ')')
pattern = '^' + pattern + '(,.*)?$'
if element_names == []:
return [sn for sn in _species_db.names if re.fullmatch(pattern, sn)]
else:
return [
s.name for s in _species_db
if re.fullmatch(pattern, s.name) and
(len(elements) == 0 or set(s.composition.keys()).issubset(elements))]
class fluid_system:
"""A class to represent a fluid_system defined by a set of selected species.
Attributes:
species_names (list[str]): List of selected species in the fluid_system
array_molar_mass (FloatArray): Array of the molar masses of the species in the fluid_system
array_element_composition (FloatArray): Array of the element composition of the species in the fluid_system.
Dimension is: (number of species, number of elements)
array_atomic_mass (FloatArray): Array of the atomic masses of the elements in the fluid_system
"""
def __init__(self, species: list[str] | str, t_min: int = 250, t_max: int = 2000):
"""Instantiates a fluid_system.
Args:
species: List of species names to be available in the constructed
fluid_system (as list of strings or a comma separated string)
t_min: Lower bound of the required temperature range in Kelvin
t_max: Upper bound of the required temperature range in Kelvin
"""
if isinstance(species, str):
species = [s.strip() for s in species.split(',')]
temperature_base_points = range(int(t_min), ceil(t_max))
data_shape = (len(temperature_base_points), len(species))
self._cp_array = np.zeros(data_shape)
self._h_array = np.zeros(data_shape)
self._s_array = np.zeros(data_shape)
# self._g_array = np.zeros(data_shape)
self._g_rt_array = np.zeros(data_shape)
self._t_offset = int(t_min)
self.species = species
self.active_species = species
element_compositions: list[dict[str, int]] = list()
for i, s in enumerate(species):
species_data = _species_db.read(s)
if not species_data:
raise Exception(f'Species {s} not found')
element_compositions.append(species_data.composition)
assert species_data.model == 9, 'Only NASA9 polynomials are supported'
for t1, t2, a in zip(species_data.t_range[:-1], species_data.t_range[1:], species_data.data):
for j, T in enumerate(temperature_base_points):
if t2 >= T >= t1:
self._cp_array[j, i] = R * (a[0]*T**-2 + a[1]*T**-1 + a[2] + a[3]*T
+ a[4]*T**2 + a[5]*T**3 + a[6]*T**4)
self._h_array[j, i] = R*T * (-a[0]*T**-2 + a[1]*ln(T)/T + a[2]
+ a[3]/2*T + a[4]/3*T**2 + a[5]/4*T**3
+ a[6]/5*T**4 + a[7]/T)
self._s_array[j, i] = R * (-a[0]/2*T**-2 - a[1]*T**-1 + a[2]*ln(T)
+ a[3]*T + a[4]/2*T**2 + a[5]/3*T**3
+ a[6]/4*T**4 + a[8])
#self._g_array[j, i] = self._h_array[j, i] - self._s_array[j, i] * T
self._g_rt_array[j, i] = (self._h_array[j, i] / T - self._s_array[j, i]) / R
# TODO: Check if temperature range is not available
# print(f'Warning: temperature ({T}) out of range for {s}')
self.elements: list[str] = sorted(list(set(k for ac in element_compositions for k in ac.keys())))
self.array_species_elements = np.array([[ec[el] if el in ec else 0.0 for el in self.elements] for ec in element_compositions])
self.array_atomic_mass = np.array([atomic_weights[el] for el in self.elements]) * 1e-3 # kg/mol
self.array_molar_mass: FloatArray = np.sum(self.array_atomic_mass * self.array_species_elements, axis=-1) # kg/mol
self.array_stoichiometric_coefficients: FloatArray = np.array(null_space(self.array_species_elements.T), dtype=NDFloat).T
def get_species_h(self, t: float | FloatArray) -> FloatArray:
"""Get the molar enthalpies for all species in the fluid system
Args:
t: Temperature in Kelvin (can be an array)
Returns:
Array with the enthalpies of each specie in J/mol
"""
return lookup(self._h_array, t, self._t_offset)
def get_species_s(self, t: float | FloatArray) -> FloatArray:
"""Get the molar entropies for all species in the fluid system
Args:
t: Temperature in Kelvin (can be an array)
Returns:
Array with the entropies of each specie in J/mol/K
"""
return lookup(self._s_array, t, self._t_offset)
def get_species_cp(self, t: float | FloatArray) -> FloatArray:
"""Get the isobaric molar heat capacity for all species in the fluid system
Args:
t: Temperature in Kelvin (can be an array)
Returns:
Array with the heat capacities of each specie in J/mol/K
"""
return lookup(self._cp_array, t, self._t_offset)
# def get_species_g(self, t: float | NDArray[_Float]) -> NDArray[_Float]:
# return lookup(self._g_array, t, self._t_offset)
def get_species_g_rt(self, t: float | FloatArray) -> FloatArray:
"""Get specific gibbs free energy divided by RT for all species in the
fluid system (g/R/T == (h/T-s)/R )
Args:
t: Temperature in Kelvin (can be an array)
Returns:
Array of gibbs free energy divided by RT (dimensionless)
"""
return lookup(self._g_rt_array, t, self._t_offset)
def get_species_references(self) -> str:
"""Get a string with the references for all fluids of the fluid system
Returns:
String with the references
"""
return '\n'.join([f'{s:<12}: {_species_db[s].ref_string}' for s in self.species])
def __add__(self, other: 'fluid_system') -> 'fluid_system':
assert isinstance(other, self.__class__)
return self.__class__(self.species + other.species)
def __repr__(self) -> str:
return ('Fluid system\n Species: ' + ', '.join(self.species) +
'\n Elements: ' + ', '.join(self.elements))
class fluid:
"""A class to represent a fluid defined by a composition of
one or more species.
Attributes:
species (list[str]): List of species names in the associated fluid_system
array_composition (FloatArray): Array of the molar amounts of the species in the fluid
array_element_composition (FloatArray): Array of the element composition in the fluid
array_fractions (FloatArray): Array of the molar fractions of the species in the fluid
total (FloatArray | float): Array of the sums of the molar amount of all species
fs (fluid_system): Reference to the fluid_system used for this fluid
"""
__array_priority__ = 100
def __init__(self, composition: dict[str, float] | list[float] | FloatArray,
fs: fluid_system | None = None,
shape: Sequence[int] | None = None):
"""Instantiates a fluid.
Args:
composition: A dict of species names with their composition, e.g.:
{'O2':0.5,'H2O':0.5} or a list/numpy-array of compositions.
The array can be multidimensional, the size of the last dimension
must match the number of species defined for the fluid_system.
The indices of the last dimension correspond to the indices in
the active_species list of the fluid_system.
fs: Reference to a fluid_system. Is optional if composition is
defined by a dict. If not specified a new fluid_system with
the components from the dict is created.
shape: Tuple or list for the dimensions the fluid array. Can
only be used if composition argument is a dict. Otherwise
the dimensions are specified by the composition argument.
"""
if fs is None:
assert isinstance(composition, dict), 'fluid system must be specified if composition is not a dict'
fs = fluid_system(list(composition.keys()))
if isinstance(composition, list):
composition = np.array(composition)
if isinstance(composition, dict):
missing_species = [s for s in composition if s not in fs.species]
if len(missing_species):
raise Exception(f'Species {missing_species[0]} is not part of the fluid system')
species_composition = [composition[s] if s in composition.keys() else 0 for s in fs.species]
comp_array = np.array(species_composition, dtype=NDFloat)
if shape is not None:
comp_array = comp_array * np.ones(list(shape) + [len(fs.species)], dtype=NDFloat)
else:
assert shape is None, 'specify shape by the shape of the composition array.'
assert composition.shape[-1] == len(fs.species), f'composition.shape[-1] ({composition.shape[-1]}) must be {len(fs.species)}'
comp_array = composition
self.array_composition: FloatArray = comp_array
self.total: FloatArray | float = np.sum(self.array_composition, axis=-1, dtype=NDFloat)
self.array_fractions: FloatArray = self.array_composition / (np.expand_dims(self.total, -1) + epsy)
self.shape: _Shape = self.array_composition.shape[:-1]
self.fs = fs
self.array_elemental_composition: FloatArray = np.dot(self.array_composition, fs.array_species_elements)
self.species = fs.species
self.elements = fs.elements
def get_composition_dict(self) -> dict[str, float]:
"""Get a dict of the molar amount of each fluid species
Returns:
Returns a dict of floats with the molar amount of each fluid species in mol
"""
return {s: c for s, c in zip(self.fs.species, self.array_composition)}
def get_fractions_dict(self) -> dict[str, float]:
"""Get a dict of the molar fractions of each fluid species
Returns:
Returns a dict of floats with the molar fractions of each fluid species
"""
return {s: c for s, c in zip(self.fs.species, self.array_fractions)}
def get_h(self, t: float | FloatArray) -> FloatArray | float:
"""Get specific enthalpy of the fluid at the given temperature
Enthalpy is referenced to 25 °C and includes enthalpy of formation.
Therefore the enthalpy of H2 and O2 is 0 at 25 °C, but the enthalpy
of water vapor at 25 °C is 241 kJ/mol (enthalpy of formation).
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Enthalpies in J/mol
"""
return np.sum(self.fs.get_species_h(t) * self.array_fractions, axis=-1, dtype=NDFloat)
def get_H(self, t: float | FloatArray) -> FloatArray | float:
"""Get absolute enthalpy of the fluid at the given temperature
Enthalpy is referenced to 25 °C and includes enthalpy of formation.
Therefore the enthalpy of H2 and O2 is 0 at 25 °C, but the enthalpy
of water vapor at 25 °C is 241 kJ/mol (enthalpy of formation).
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Enthalpies in J
"""
return np.sum(self.fs.get_species_h(t) * self.array_composition, axis=-1, dtype=NDFloat)
def get_s(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get molar entropy of the fluid at the given temperature and pressure
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Entropy in J/mol/K
"""
x = self.array_fractions
s = self.fs.get_species_s(t)
return np.sum(x * (s - R * np.log(np.expand_dims(p / p0, -1) * x + epsy)), axis=-1, dtype=NDFloat)
def get_S(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get absolute entropy of the fluid at the given temperature and pressure
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Entropy in J/K
"""
x = self.array_fractions
n = self.array_composition
s = self.fs.get_species_s(t)
return np.sum(n * (s - R * np.log(np.expand_dims(p / p0, -1) * x + epsy)), axis=-1, dtype=NDFloat)
def get_cp(self, t: float | FloatArray) -> FloatArray | float:
"""Get molar heat capacity at constant pressure
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Heat capacity in J/mol/K
"""
return np.sum(self.fs.get_species_cp(t) * self.array_fractions, axis=-1, dtype=NDFloat)
def get_g(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get molar gibbs free energy (h - Ts)
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
p: Pressures(s) in Pascal. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Gibbs free energy in J/mol
"""
x = self.array_fractions
grt = self.fs.get_species_g_rt(t)
return R * t * np.sum(x * (grt + np.log(np.expand_dims(p / p0, -1) * x + epsy)), axis=-1, dtype=NDFloat)
def get_G(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get absolute gibbs free energy (H - TS)
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
p: Pressures(s) in Pascal. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Gibbs free energy in J
"""
x = self.array_fractions
n = self.array_composition
grt = self.fs.get_species_g_rt(t)
return R * t * np.sum(n * (grt + np.log(np.expand_dims(p / p0, -1) * x + epsy)), axis=-1, dtype=NDFloat)
def get_g_rt(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get specific gibbs free energy divided by RT: g/R/T == (h/T-s)/R
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
p: Pressures(s) in Pascal. Fluid shape and shape of the temperature
must be broadcastable
Returns:
Gibbs free energy divided by RT (dimensionless)
"""
x = self.array_fractions
grt = self.fs.get_species_g_rt(t)
return np.sum(x * (grt + np.log(np.expand_dims(p / p0, -1) * x + epsy)), axis=-1, dtype=NDFloat)
def get_v(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get Absolute fluid volume
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
p: Pressure in Pa. Fluid shape and shape of the pressure
must be broadcastable
Returns:
Volume of the fluid in
"""
return R / p * t * self.total
def get_vm(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get molar fluid volume
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
p: Pressure in Pa. Fluid shape and shape of the pressure
must be broadcastable
Returns:
Molar volume of the fluid in /mol
"""
return R / p * t
def get_mass(self) -> FloatArray | float:
"""Get Absolute fluid mass
Returns:
Mass of the fluid in kg
"""
return np.sum(self.array_composition * self.fs.array_molar_mass, axis=-1, dtype=NDFloat)
def get_molar_mass(self) -> FloatArray | float:
"""Get molar fluid mass
Returns:
Mass of the fluid in kg/mol
"""
return np.sum(self.array_fractions * self.fs.array_molar_mass, axis=-1, dtype=NDFloat)
def get_density(self, t: float | FloatArray, p: float | FloatArray) -> FloatArray | float:
"""Get mass based fluid density
Args:
t: Absolute temperature(s) in Kelvin. Fluid shape and shape of the temperature
must be broadcastable
p: Pressure in Pa. Fluid shape and shape of the pressure
must be broadcastable
Returns:
Density of the fluid in kg/
"""
return np.sum(self.array_fractions * self.fs.array_molar_mass, axis=-1, dtype=NDFloat) / (R * t) * p
def get_x(self, species: str | list[str] | None = None) -> FloatArray:
"""Get molar fractions of fluid species
Args:
species: A single species name, a list of species names or None for
returning the molar fractions of all species
Returns:
Returns an array of floats with the molar fractions of the species.
If the a single species name is provided the return float array has
the same dimensions as the fluid type. If a list or None is provided
the return array has an additional dimension for the species.
"""
if not species:
return self.array_fractions
elif isinstance(species, str):
assert species in self.fs.species, f'Species {species} is not part of the fluid system'
return self.array_fractions[..., self.fs.species.index(species)]
else:
assert set(species) <= set(self.fs.species), f'Species {", ".join([s for s in species if s not in self.fs.species])} is/are not part of the fluid system'
return self.array_fractions[..., [self.fs.species.index(k) for k in species]]
def __add__(self, other: T) -> T:
return array_operation(self, other, np.add)
def __sub__(self, other: T) -> T:
return array_operation(self, other, np.subtract)
def __truediv__(self, other: int | float | NDArray[Any]) -> 'fluid':
if isinstance(other, np.ndarray):
k = np.expand_dims(other, -1)
else:
k = np.array(other, dtype=NDFloat)
return self.__class__(self.array_composition / k, self.fs)
def __mul__(self, other: int | float | NDArray[Any]) -> 'fluid':
k = np.expand_dims(other, -1) if isinstance(other, np.ndarray) else other
return self.__class__(self.array_composition * k, self.fs)
def __rmul__(self, other: int | float | NDArray[Any]) -> 'fluid':
k = np.expand_dims(other, -1) if isinstance(other, np.ndarray) else other
return self.__class__(self.array_composition * k, self.fs)
def __neg__(self) -> 'fluid':
return self.__class__(-self.array_composition, self.fs)
# def __array__(self) -> FloatArray:
# return self.array_composition
def __getitem__(self, key: str | int | list[str] | list[int] | slice) -> FloatArray:
if isinstance(key, str):
assert key in self.fs.species, f'Species {key} is not part of the fluid system'
return self.array_composition[..., self.fs.species.index(key)]
elif isinstance(key, (slice, int)):
return self.array_composition[..., key]
else:
mset = set(self.fs.species) | set(range(len(self.fs.species)))
assert set(key) <= mset, f'Species {", ".join([str(s) for s in key if s not in mset])} is/are not part of the fluid system'
return self.array_composition[..., [self.fs.species.index(k) if isinstance(k, str) else k for k in key]]
def __iter__(self) -> Iterator[dict[str, float]]:
return iter({s: c for s, c in zip(self.fs.species, spa)} for spa in np.array(self.array_composition, ndmin=2))
def __repr__(self) -> str:
if len(self.array_fractions.shape) == 1:
lines = [f'{s:16} {c * 100:5.2f} %' for s, c in zip(self.fs.species, self.array_fractions)]
return f'{"Total":16} {self.total:8.3e} mol\n' + '\n'.join(lines)
else:
array_disp = self.array_fractions.__repr__()
padding = int(array_disp.find('\n') / (len(self.fs.species) + 1))
return ('Total mol:\n' + self.total.__repr__() +
'\nSpecies:\n' + ' ' * int(padding / 2) +
''.join([(' ' * (padding - len(s))) + s for s in self.fs.species]) +
'\nMolar fractions:\n' + self.array_fractions.__repr__())
class elements:
"""Represent a fluid by composition of elements.
Attributes:
array_element_composition (FloatArray): Array of the element composition
"""
__array_priority__ = 100
def __init__(self, composition: fluid | dict[str, float] | list[str] | list[float] | FloatArray,
fs: fluid_system | None = None, shape: Sequence[int] | None = None):
"""Instantiates an elements object.
Args:
composition: A fluid object, a dict of element names with their
composition, e.g.: {'O':1,'H':2} or a list/numpy-array of compositions.
The array can be multidimensional, the size of the last dimension
must match the number of elements used in the fluid_system.
The indices of the last dimension correspond to the indices in
the active_species list of the fluid_system.
fs: Reference to a fluid_system.
shape: Tuple or list for the dimensions the fluid array. Can
only be used if composition argument is a dict. Otherwise
the dimensions are specified by the composition argument.
"""
if isinstance(composition, list):
composition = np.array(composition)
if isinstance(composition, fluid):
new_composition: FloatArray = np.dot(composition.array_composition, composition.fs.array_species_elements)
if fs:
self.array_elemental_composition = reorder_array(new_composition, composition.fs.elements, fs.elements)
else:
self.array_elemental_composition = new_composition
fs = composition.fs
elif isinstance(composition, dict) and fs is None:
fs = fluid_system(species(element_names=list(composition.keys())))
else:
assert fs, 'fluid system must be specified if composition is not specified by a fluid'
if isinstance(composition, dict):
missing_elements = [s for s in composition if s not in fs.elements]
if len(missing_elements):
raise Exception(f'Element {missing_elements[0]} is not part of the fluid system')
self.array_elemental_composition = np.array([composition[s] if s in composition.keys() else 0 for s in fs.elements])
if shape is not None:
self.array_elemental_composition = self.array_elemental_composition * np.ones(list(shape) + [len(fs.species)])
elif isinstance(composition, np.ndarray):
assert shape is None, 'specify shape by the shape of the composition array.'
assert composition.shape[-1] == len(fs.elements), f'composition.shape[-1] ({composition.shape[-1]}) must be {len(fs.elements)}'
self.array_elemental_composition = composition
self.shape: _Shape = self.array_elemental_composition.shape[:-1]
self.fs = fs
self.elements = fs.elements
def get_elemental_composition(self) -> dict[str, float]:
"""Get a dict of the molar amount of each element
Returns:
Returns a dict of floats with the molar amount of each element in mol
"""
return {s: c for s, c in zip(self.fs.elements, self.array_elemental_composition)}
def get_mass(self) -> FloatArray | float:
"""Get absolute mass of elements
Returns:
Mass of the fluid in kg
"""
return np.sum(self.array_elemental_composition * self.fs.array_atomic_mass, axis=-1, dtype=NDFloat)
def __add__(self, other: 'fluid | elements') -> 'elements':
return array_operation(self, other, np.add)
def __sub__(self, other: 'fluid | elements') -> 'elements':
return array_operation(self, other, np.subtract)
def __truediv__(self, other: int | float | FloatArray) -> 'elements':
k = np.expand_dims(other, -1) if isinstance(other, np.ndarray) else other
ttes = self.array_elemental_composition / k
return self.__class__(self.array_elemental_composition / k + ttes, self.fs)
def __mul__(self, other: int | float | FloatArray) -> 'elements':
k = np.expand_dims(other, -1) if isinstance(other, np.ndarray) else other
return self.__class__(self.array_elemental_composition * k, self.fs)
def __rmul__(self, other: int | float | FloatArray) -> 'elements':
k = np.expand_dims(other, -1) if isinstance(other, np.ndarray) else other
return self.__class__(self.array_elemental_composition * k, self.fs)
def __neg__(self) -> 'elements':
return self.__class__(-self.array_elemental_composition, self.fs)
def __array__(self) -> FloatArray:
return self.array_elemental_composition
def __getitem__(self, key: str | int | list[str] | list[int] | slice) -> FloatArray:
if isinstance(key, str):
assert key in self.fs.elements, f'Element {key} is not part of the fluid system'
return self.array_elemental_composition[..., self.fs.elements.index(key)]
elif isinstance(key, (slice, int)):
return self.array_elemental_composition[..., key]
else:
mset = set(self.fs.elements) | set(range(len(self.fs.elements)))
assert set(key) <= mset, f'Elements {", ".join([str(s) for s in key if s not in mset])} is/are not part of the fluid system'
return self.array_elemental_composition[..., [self.fs.elements.index(k) if isinstance(k, str) else k for k in key]]
def __iter__(self) -> Iterator[dict[str, float]]:
return iter({s: c for s, c in zip(self.fs.elements, spa)} for spa in np.array(self.array_elemental_composition, ndmin=2))
def __repr__(self) -> str:
if len(self.array_elemental_composition.shape) == 1:
lines = [f'{s:16} {c:5.3e} mol' for s, c in zip(self.fs.elements, self.array_elemental_composition)]
return '\n'.join(lines)
else:
array_disp = self.array_elemental_composition.__repr__()
padding = int(array_disp.find('\n') / (len(self.fs.elements) + 1))
return ('Elements:\n' + ' ' * int(padding / 2) +
''.join([(' ' * (padding - len(s))) + s for s in self.fs.elements]) +
'\nMols:\n' + self.array_elemental_composition.__repr__())
# def _combine_index(index1: list[str], index2: list[str]) -> list[str]:
# return list(set(index1) | set(index2))
def lookup(prop_array: FloatArray,
temperature: FloatArray | float,
t_offset: float) -> FloatArray:
"""linear interpolates values from the given prop_array
Args:
prop_array: Array of the temperature depended property
temperature: Absolute temperature(s) in Kelvin. Must
be broadcastable to prop_array.
Returns:
Interpolates values based on given temperature
"""
t = np.array(temperature) - t_offset
t_lim = np.minimum(np.maximum(0, t), prop_array.shape[0] - 2)
f = np.expand_dims(t - np.floor(t_lim), axis=-1)
ti1 = t_lim.astype(int)
return f * prop_array[ti1 + 1, :] + (1 - f) * prop_array[ti1, :]
def reorder_array(arr: FloatArray, old_index: list[str], new_index: list[str]) -> FloatArray:
"""Reorder the last dimension of an array according to a provided list of species
names in the old oder and a list in the new order.
Args:
arr: Array to be reordered
old_index: List of species names in the current order
new_index: List of species names in the new order
Returns:
Array with the last dimension reordered
"""
ret_array = np.zeros([*arr.shape[:-1], len(new_index)])
for i, k in enumerate(old_index):
ret_array[..., new_index.index(k)] = arr[..., i]
return ret_array
@overload
def array_operation(self: elements, other: elements | fluid, func: Callable[[FloatArray, FloatArray], FloatArray]) -> elements:
pass
@overload
def array_operation(self: fluid, other: T, func: Callable[[FloatArray, FloatArray], FloatArray]) -> T:
pass
@overload
def array_operation(self: T, other: fluid, func: Callable[[FloatArray, FloatArray], FloatArray]) -> T:
pass
def array_operation(self: elements | fluid, other: elements | fluid, func: Callable[[FloatArray, FloatArray], FloatArray]) -> elements | fluid:
"""Perform an array operation on two fluid or elements objects.
The operation is provided by a Callable that takes two arguments.
Args:
self: First fluid or elements object
other: Second fluid or elements object
func: Callable function to perform the operation
Returns:
A new fluid or elements object with the result of the
"""
assert isinstance(other, elements) or isinstance(other, fluid)
if self.fs is other.fs:
if isinstance(self, elements) or isinstance(other, elements):
return elements(func(self.array_elemental_composition, other.array_elemental_composition), self.fs)
else:
return fluid(func(self.array_composition, other.array_composition), self.fs)
elif set(self.fs.species) >= set(other.fs.species):
if isinstance(self, elements) or isinstance(other, elements):
el_array = reorder_array(other.array_elemental_composition, other.fs.elements, self.fs.elements)
return elements(func(self.array_elemental_composition, el_array), self.fs)
else:
el_array = reorder_array(other.array_composition, other.fs.species, self.fs.species)
return fluid(func(self.array_composition, el_array), self.fs)
elif set(self.fs.species) < set(other.fs.species):
if isinstance(self, elements) or isinstance(other, elements):
el_array = reorder_array(self.array_elemental_composition, self.fs.elements, other.fs.elements)
return elements(func(el_array, other.array_elemental_composition), other.fs)
else:
el_array = reorder_array(self.array_composition, self.fs.species, other.fs.species)
return fluid(func(el_array, other.array_composition), other.fs)
else:
new_fs = fluid_system(sorted(list(set(self.fs.species) | set(other.fs.species))))
if isinstance(self, elements) or isinstance(other, elements):
el_array1 = reorder_array(self.array_elemental_composition, self.fs.elements, new_fs.elements)
el_array2 = reorder_array(other.array_elemental_composition, other.fs.elements, new_fs.elements)
return elements(func(el_array1, el_array2), new_fs)
else:
el_array1 = reorder_array(self.array_composition, self.fs.species, new_fs.species)
el_array2 = reorder_array(other.array_composition, other.fs.species, new_fs.species)
return fluid(func(el_array1, el_array2), new_fs)

296
src/gaspype/_operations.py Normal file
View File

@ -0,0 +1,296 @@
from typing import Literal, Any
from math import exp
from scipy.optimize import minimize, root
import numpy as np
from ._main import T, NDFloat, elements, fluid, fluid_system, FloatArray
from .constants import p0, epsy, R
def set_solver(solver: Literal['gibs minimization', 'system of equations']) -> None:
"""
Select a solver for chemical equilibrium.
Solvers:
- **system of equations** (default): Finds the root for a system of
equations covering a minimal set of equilibrium equations and elemental balance.
The minimal set of equilibrium equations is derived by SVD using the null_space
implementation of scipy.
- **gibs minimization**: Minimizes the total Gibbs Enthalpy while keeping
the elemental composition constant using the SLSQP implementation of scipy
Args:
solver: Name of the solver
"""
global _equilibrium_solver
if solver == 'gibs minimization':
_equilibrium_solver = equilibrium_gmin
elif solver == 'system of equations':
_equilibrium_solver = equilibrium_eq
else:
raise ValueError('Unknown solver')
def get_solver() -> Literal['gibs minimization', 'system of equations']:
"""Returns the selected solver name.
Returns:
Solver name
"""
if _equilibrium_solver == equilibrium_gmin:
return 'gibs minimization'
else:
assert _equilibrium_solver == equilibrium_eq
return 'system of equations'
def stack(arrays: list[T], axis: int = 0) -> T:
"""Stack a list of fluid or elements objects along a new axis
Args:
arrays: List of arrays
axis: Axis to stack the fluid objects along
Returns:
A new array object stacked along the new axis
"""
a0 = arrays[0]
assert all(a.fs == a0.fs for a in arrays), 'All objects must have the same fluid system'
assert axis <= len(a0.shape), f'Axis must be smaller or equal to len(shape) ({len(a0.shape)})'
return a0.__class__(np.stack(
[a.array_elemental_composition if isinstance(a, elements) else a.array_composition for a in arrays],
axis=axis), a0.fs)
def concat(arrays: list[T], axis: int = 0) -> T:
"""Concatenate a list of fluid or elements objects along an existing axis
Args:
arrays: List of arrays
axis: Axis to concatenate the fluid objects along
Returns:
A new array object stacked along the specified axis
"""
a0 = arrays[0]
assert all(f.fs == a0.fs for f in arrays), 'All fluid objects must have the same fluid system'
assert axis < len(a0.shape), f'Axis must be smaller than shape len({a0.shape})'
return a0.__class__(np.concatenate(
[a.array_elemental_composition if isinstance(a, elements) else a.array_composition for a in arrays],
axis=axis), a0.fs)
def equilibrium_gmin(fs: fluid_system, element_composition: FloatArray, t: float, p: float) -> FloatArray:
"""Calculate the equilibrium composition of a fluid based on minimizing the Gibbs free energy"""
def element_balance(n: FloatArray, fs: fluid_system, ref: FloatArray) -> FloatArray:
return np.dot(n, fs.array_species_elements) - ref # type: ignore
def gibbs_rt(n: FloatArray, grt: FloatArray, p_rel: float): # type: ignore
# Calculate G/(R*T)
return np.sum(n * (grt + np.log(p_rel * n / np.sum(n) + epsy)))
cons: dict[str, Any] = {'type': 'eq', 'fun': element_balance, 'args': [fs, element_composition]}
bnds = [(0, None) for _ in fs.species]
grt = fs.get_species_g_rt(t)
p_rel = p / p0
start_composition_array = np.ones_like(fs.species, dtype=float)
sol = np.array(minimize(gibbs_rt, start_composition_array, args=(grt, p_rel), method='SLSQP',
bounds=bnds, constraints=cons, options={'maxiter': 2000, 'ftol': 1e-12})['x'], dtype=NDFloat) # type: ignore
return sol
def equilibrium_eq(fs: fluid_system, element_composition: FloatArray, t: float, p: float) -> FloatArray:
"""Calculate the equilibrium composition of a fluid based on equilibrium equations"""
el_max = np.max(element_composition)
element_norm = element_composition / el_max
a = fs.array_stoichiometric_coefficients
a_sum = np.sum(a)
el_matrix = fs.array_species_elements.T
# Log equilibrium constants for each reaction equation
b = -np.sum(fs.get_species_g_rt(t) * a, axis=1)
# Pressure corrected log equilibrium constants
bp = b - np.sum(a * np.log(p / p0), axis=1)
logn_start = np.ones(el_matrix.shape[1]) * 0.1
def residuals(logn: FloatArray): # type: ignore
n = np.exp(logn)
n_sum = np.sum(n)
# Residuals from equilibrium equations:
eq_resid = np.dot(a, logn - np.log(n_sum)) - bp
# Derivative:
j_eq = a - a_sum * n / n_sum
# Residuals from elemental balance:
el_error = np.dot(el_matrix, n) - element_norm
ab_resid = np.log1p(el_error)
# Derivative:
j_ab = el_matrix * n / np.expand_dims(el_error + 1, axis=1)
return (np.hstack([eq_resid, ab_resid]), np.concatenate([j_eq, j_ab], axis=0))
ret = root(residuals, logn_start, jac=True, tol=1e-30)
n = np.exp(np.array(ret['x'], dtype=NDFloat))
return n * el_max
def equilibrium(f: fluid | elements, t: float | FloatArray, p: float = 1e5) -> fluid:
"""Calculate the equilibrium composition of a fluid at a given temperature and pressure"
Args:
f: Fluid or elements object
t: Temperature in Kelvin
p: Pressure in Pascal
Returns:
A new fluid object with the equilibrium composition
"""
assert isinstance(f, (fluid, elements)), 'Argument f must be a fluid or elements'
m_shape: int = f.fs.array_stoichiometric_coefficients.shape[0]
if isinstance(f, fluid):
if not m_shape:
return f
else:
if not m_shape:
def linalg_lstsq(array_elemental_composition: FloatArray, matrix: FloatArray) -> Any:
# TODO: np.dot(np.linalg.pinv(a), b) is eqivalent to lstsq(a, b).
# the constant np.linalg.pinv(a) can be precomputed for each fs.
return np.dot(np.linalg.pinv(matrix), array_elemental_composition)
print('-->', f.array_elemental_composition.shape, f.fs.array_species_elements.transpose().shape)
composition = np.apply_along_axis(linalg_lstsq, -1, f.array_elemental_composition, f.fs.array_species_elements.transpose())
return fluid(composition, f.fs)
assert np.min(f.array_elemental_composition) >= 0, 'Input element fractions must be 0 or positive'
if isinstance(t, np.ndarray):
assert f.shape == tuple(), 'Multidimensional temperature can currently only used for 0D fluids'
t_composition = np.zeros(t.shape + (f.fs.array_species_elements.shape[0],))
for t_index in np.ndindex(t.shape):
t_composition[t_index] = _equilibrium_solver(f.fs, f.array_elemental_composition, t[t_index], p)
return fluid(t_composition, f.fs)
else:
composition = np.ones(f.shape + (len(f.fs.species),), dtype=float)
for index in np.ndindex(f.shape):
#print(composition.shape, index, _equilibrium(f.fs, f._element_composition[index], t, p))
composition[index] = _equilibrium_solver(f.fs, f.array_elemental_composition[index], t, p)
return fluid(composition, f.fs)
def carbon_activity(f: fluid | elements, t: float, p: float) -> float:
"""Calculate the activity of carbon in a fluid at a given temperature and pressure.
At a value of 1 the fluid is in equilibrium with solid graphite. At a value > 1
additional carbon formation is thermodynamic favored. At a value < 1 a
depletion of solid carbon is favored.
Args:
f: Fluid or elements object
t: Temperature in Kelvin
p: Pressure in Pascal
Returns:
The activity of carbon in the fluid
"""
# Values for solid state carbon (graphite) from NIST-JANAF Tables
# https://janaf.nist.gov/pdf/JANAF-FourthEd-1998-Carbon.pdf
# https://janaf.nist.gov/pdf/JANAF-FourthEd-1998-1Vol1-Intro.pdf
# Polynomial is valid for T from 100 to 2500 K
pgef = np.array([-6.76113852E-02, 2.02187857E+00, -2.38664605E+01,
1.43575462E+02, -4.51375503E+02, 6.06219665E+02])
# Gibbs free energy divided by RT for carbon
g_rtc = -np.sum(pgef * np.log(np.expand_dims(t, -1))**np.array([5, 4, 3, 2, 1, 0])) / R
g_rt = f.fs.get_species_g_rt(t)
x = equilibrium(f, t, p).array_fractions
i_co = f.fs.species.index('CO')
i_co2 = f.fs.species.index('CO2')
i_h2 = f.fs.species.index('H2')
i_h2o = f.fs.species.index('H2O')
i_ch4 = f.fs.species.index('CH4')
if min(x[i_co], x[i_co2]) > min([x[i_ch4], x[i_h2o], x[i_h2]]) and min(x[i_co], x[i_co2]) > 0:
# 2 CO -> CO2 + C(s) (Boudouard reaction)
lnalpha = (2 * g_rt[i_co] - (g_rt[i_co2] + g_rtc)) + np.log(
x[i_co]**2 / x[i_co2] * (p / p0))
elif min([x[i_ch4], x[i_h2o], x[i_co]]) > 1E-4:
# CH4 + 2 CO -> 2 H2O + 3 C(s)
lnalpha = ((g_rt[i_ch4] + 2 * g_rt[i_co] - 3 * g_rtc - 2 * g_rt[i_h2o]) + np.log(
x[i_ch4] * x[i_co]**2 / x[i_h2o]**2 * (p / p0))) / 3
elif min(x[i_h2], x[i_ch4]) > 0:
# if x[i_h2] or x[i_ch4] is small compared to the precision of the
# component concentrations the result can be inaccurate
# CH4 -> 2 H2 + C(s)
# CH4 + CO2 -> 2 H2 + 2 CO
# 2 H2O - O2 -> 2 H2
lnalpha = (g_rt[i_ch4] - (2 * g_rt[i_h2] + g_rtc)) + np.log(
x[i_ch4] / x[i_h2]**2 / (p / p0))
elif x[i_h2] == 0:
# equilibrium on carbon side
lnalpha = 10
else:
# equilibrium on non-carbon side
lnalpha = -10
return exp(lnalpha)
def oxygen_partial_pressure(f: fluid | elements, t: float, p: float) -> FloatArray | float:
_oxygen_data = fluid({'O2': 1})
def get_oxygen(x: FloatArray) -> float:
g_rt = f.fs.get_species_g_rt(t)
g_rt_o2 = _oxygen_data.fs.get_species_g_rt(t)[0]
i_co = f.fs.species.index('CO') if 'C' in f.fs.elements else None
i_co2 = f.fs.species.index('CO2') if 'C' in f.fs.elements else None
i_o2 = f.fs.species.index('O2') if 'O2' in f.fs.species else None
i_h2o = f.fs.species.index('H2O') if 'H' in f.fs.elements else None
i_h2 = f.fs.species.index('H2') if 'H' in f.fs.elements else None
i_ch4 = f.fs.species.index('CH4') if 'CH4' in f.fs.species else None
ox_ref_val = max([float(v) for v in (x[i_h2] * x[i_h2o], x[i_co2] * x[i_co], x[i_ch4]) if v.shape == tuple()] + [0])
# print([i_o2, x[i_o2], ox_ref_val])
if i_o2 is not None and x[i_o2] > ox_ref_val:
# print('o O2')
return float(x[i_o2] * p)
elif i_ch4 is not None and x[i_ch4] > x[i_co2] * 100 and x[i_ch4] > x[i_h2o] * 100:
# print('o ch4')
# 2CH4 + O2 <--> 4H2 + 2CO
lnpo2 = 4 * g_rt[i_h2] + 2 * g_rt[i_co] - 2 * g_rt[i_ch4] - g_rt_o2 + np.log(x[i_h2]**4 * x[i_co]**2 / x[i_ch4]**2) - 2 * np.log(p / p0)
elif (i_co is None and i_h2 is not None) or (i_h2 is not None and i_co is not None and (x[i_h2] * x[i_h2o] > x[i_co2] * x[i_co])):
# print('o h2o')
# 2H2 + O2 <--> 2H2O
lnpo2 = 2 * (g_rt[i_h2o] - g_rt[i_h2] + np.log(x[i_h2o] / x[i_h2])) - g_rt_o2 - np.log(p / p0)
else:
assert i_co is not None
# print('o co2')
# 2CO + O2 <--> 2CO2
lnpo2 = 2 * (g_rt[i_co2] - g_rt[i_co] + np.log(x[i_co2] / x[i_co])) - g_rt_o2 - np.log(p / p0)
return exp(lnpo2) * p
x = equilibrium(f, t, p).array_fractions
if len(x.shape):
return np.apply_along_axis(get_oxygen, -1, x)
else:
return get_oxygen(x)
_equilibrium_solver = equilibrium_eq

24
src/gaspype/constants.py Normal file
View File

@ -0,0 +1,24 @@
"""
# Constants
This module contains physical constants used in the gas phase calculations.
kB: Boltzmann constant (J/K)
NA: Avogadro's number (1/mol)
R: Ideal gas constant (J/mol/K)
F: Faraday constant (C/mol)
p0: Standard pressure 1e5 Pa
t0: Standard temperature 298.15 K (25 °C)
p_atm: Standard atmosphere 1 atm = 101325 Pa
epsy: Small value for numerical stability (1e-18)
"""
kB = 1.380649e-23 # J/K
NA = 6.02214076e23 # 1/mol
R = kB * NA # J/mol/K
F = 96485.3321233100 # C/mol
p0 = 1e5 # Pa
t0 = 273.15 + 25 # K
p_atm = 101325 # Pa
epsy = 1e-18

View File

@ -1,4 +1,6 @@
from . import R, F, oxygen_partial_pressure, fluid, elements, FloatArray from .constants import R, F
from ._operations import oxygen_partial_pressure
from ._main import fluid, elements, FloatArray
import numpy as np import numpy as np
# Each O2 molecule is transported as two ions and each ion has a charged of 2e # Each O2 molecule is transported as two ions and each ion has a charged of 2e