Merge pull request #2 from DLR-Institute-of-Future-Fuels/example_generation_feature

- Example generation feature
- Code example added
- Unit tests and doc generation/config updated to handle examples
This commit is contained in:
Nicolas Kruse 2025-06-06 13:29:03 +02:00 committed by GitHub
commit ce48083e77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 868 additions and 116 deletions

View File

@ -14,6 +14,7 @@ exclude =
dist,
.conda,
tests/autogenerated_*,
docs/source/_autogenerated
.venv,
venv

View File

@ -17,17 +17,20 @@ jobs:
uses: actions/setup-python@v3
with:
python-version: "3.x"
- name: Install dependencies
run: pip install sphinx sphinx_rtd_theme sphinx-autodoc-typehints myst-parser
- name: Generate Class List
- name: Install gaspype and dependencies
run: |
echo "Create a dummy file to ensure the class list generation works"
echo "Create a dummy file to ensure gaspype does't crash"
mkdir -p src/gaspype/data
printf 'gapy\x00\x00\x00\x00' > src/gaspype/data/therm_data.bin
pip install .
python ./docs/source/generate_class_list.py
pip install .[doc_build]
python -m ipykernel install --user --name temp_kernel --display-name "Python (temp_kernel)"
- name: Generate Docs
run: python ./docs/source/generate_class_list.py
- name: Generate Examples
run: python ./docs/source/render_examples.py
- name: Build Docs
run: |
cp LICENSE docs/source/LICENSE.md
cd docs
sphinx-apidoc -o ./source/ ../src/ -M --no-toc
rm ./source/*.rst

2
.gitignore vendored
View File

@ -9,7 +9,7 @@ __pycache__
.pytest_cache
tests/autogenerated_*.py
docs/build/
docs/source/modules.md
docs/source/_autogenerated/
venv/
.venv/
thermo_data/combined_data.yaml

View File

@ -62,7 +62,8 @@ fl.get_density(t=t_range, p=1e5)
```
array([0.10122906, 0.09574625, 0.09082685, 0.08638827, 0.08236328])
```
A ```fluid``` object can have multiple compositions. A multidimensional ```fluid``` object can be created for example by multiplication with a numpy array:
A ```fluid``` object can have multiple compositions. A multidimensional ```fluid``` object
can be created for example by multiplication with a numpy array:
``` python
fl2 = gp.fluid({'H2O': 1, 'N2': 2}) + \
@ -125,7 +126,8 @@ array([[[0. , 0.5 , 0.5 ],
```
### Elements
In some cases not the molecular but the atomic composition is of interest. The ```elements``` class can be used for atom based balances and works similar:
In some cases not the molecular but the atomic composition is of interest.
The ```elements``` class can be used for atom based balances and works similar:
``` python
el = gp.elements({'N': 1, 'Cl': 2})
@ -134,7 +136,9 @@ el.get_mass()
```
np.float64(0.08490700000000001)
```
A ```elements``` object can be as well instantiated from a ```fluid``` object. Arithmetic operations between ```elements``` and ```fluid``` result in an ```elements``` object:
A ```elements``` object can be as well instantiated from a ```fluid``` object.
Arithmetic operations between ```elements``` and ```fluid``` result in
an ```elements``` object:
``` python
el2 = gp.elements(fl) + el - 0.3 * fl
el2
@ -146,7 +150,8 @@ N 1.000e+00 mol
O 7.000e-01 mol
```
Going from an atomic composition to an molecular composition is a little bit less straight forward, since there is no universal approach. One way is to calculate the thermodynamic equilibrium for a mixture:
Going from an atomic composition to an molecular composition is possible as well.
One way is to calculate the thermodynamic equilibrium for a mixture:
``` python
fs = gp.fluid_system('CH4, H2, CO, CO2, O2')
@ -163,7 +168,13 @@ CO2 33.07 %
O2 0.00 %
```
The ```equilibrium``` function can be called with a ```fluid``` or ```elements``` object as first argument. ```fluid``` and ```elements``` referencing a ```fluid_system``` object witch can be be set as shown above during the object instantiation. If not provided, a new one will be created automatically. Providing a ```fluid_system``` gives more control over which molecular species are included in derived ```fluid``` objects. Furthermore arithmetic operations between objects with the same ```fluid_system``` are potentially faster:
The ```equilibrium``` function can be called with a ```fluid``` or ```elements``` object
as first argument. ```fluid``` and ```elements``` referencing a ```fluid_system``` object
witch can be be set as shown above during the object instantiation. If not provided,
a new one will be created automatically. Providing a ```fluid_system``` gives more
control over which molecular species are included in derived ```fluid``` objects.
Furthermore arithmetic operations between objects with the same ```fluid_system```
are potentially faster:
``` python
fl3 + gp.fluid({'CH4': 1}, fs)
@ -177,7 +188,9 @@ CO2 18.07 %
O2 0.00 %
```
Especially if the ```fluid_system``` of one of the operants has not a subset of molecular species of the other ```fluid_system``` a new ```fluid_system``` will be created for the operation which might degrade performance:
Especially if the ```fluid_system``` of one of the operants has not a subset of
molecular species of the other ```fluid_system``` a new ```fluid_system``` will
be created for the operation which might degrade performance:
``` python
fl3 + gp.fluid({'NH3': 1})

193
docs/media/soc_inverted.svg Normal file
View File

@ -0,0 +1,193 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="164.70512mm"
height="86.731506mm"
viewBox="0 0 164.70512 86.731506"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
sodipodi:docname="soc_inverted.svg"
inkscape:export-filename="soc_export.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.0041055"
inkscape:cx="425.25411"
inkscape:cy="332.13641"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<marker
style="overflow:visible"
id="Triangle"
refX="0"
refY="0"
orient="auto-start-reverse"
inkscape:stockid="Triangle arrow"
markerWidth="2"
markerHeight="1"
viewBox="0 0 1 1"
inkscape:isstock="true"
inkscape:collect="always"
preserveAspectRatio="none">
<path
transform="scale(0.5)"
style="fill:context-stroke;fill-rule:evenodd;stroke:context-stroke;stroke-width:1pt"
d="M 5.77,0 -2.88,5 V -5 Z"
id="path135" />
</marker>
</defs>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-18.027099,-19.242669)">
<path
style="fill:none;stroke:#dadada;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#Triangle);marker-end:url(#Triangle)"
d="M 23.663111,22.545169 V 97.81995 H 179.42973"
id="path1" />
<path
style="fill:none;stroke:#dadada;stroke-width:0.372829;stroke-dasharray:none;stroke-opacity:1"
d="M 170.85884,97.81995 V 95.304579"
id="path2" />
<circle
style="fill:#dddddd;fill-opacity:1;stroke:#dadada;stroke-width:0.468334;stroke-dasharray:none;stroke-opacity:1"
id="path3"
cx="40.768127"
cy="46.63377"
r="1.2658331" />
<circle
style="fill:#dddddd;fill-opacity:1;stroke:#dadada;stroke-width:0.468334;stroke-dasharray:none;stroke-opacity:1"
id="path3-7"
cx="60.487274"
cy="54.953339"
r="1.2658331" />
<circle
style="fill:#dddddd;fill-opacity:1;stroke:#dadada;stroke-width:0.468334;stroke-dasharray:none;stroke-opacity:1"
id="path3-6"
cx="94.775032"
cy="67.060722"
r="1.2658331" />
<circle
style="fill:#dddddd;fill-opacity:1;stroke:#dadada;stroke-width:0.468334;stroke-dasharray:none;stroke-opacity:1"
id="path3-8"
cx="133.16524"
cy="76.384407"
r="1.2658331" />
<circle
style="fill:#dddddd;fill-opacity:1;stroke:#dadada;stroke-width:0.468334;stroke-dasharray:none;stroke-opacity:1"
id="path3-1"
cx="23.607611"
cy="32.419498"
r="1.2658331" />
<circle
style="fill:#dddddd;fill-opacity:1;stroke:#dadada;stroke-width:0.468334;stroke-dasharray:none;stroke-opacity:1"
id="path3-8-3"
cx="170.49799"
cy="83.681023"
r="1.2658331" />
<path
style="fill:none;fill-opacity:1;stroke:#dadada;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1"
d="m 23.61653,32.379465 17.141782,14.300346 19.796893,8.151661 34.283565,12.111043 38.38268,9.502507 37.26475,7.266625"
id="path4"
sodipodi:nodetypes="cccccc" />
<text
xml:space="preserve"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1"
x="86.083488"
y="105.09981"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465"
x="86.083488"
y="105.09981">Relative cell lenghs</tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1"
x="169.54897"
y="103.00477"
id="text4-1"><tspan
sodipodi:role="line"
id="tspan4-1"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465"
x="169.54897"
y="103.00477">1</tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1"
x="22.457224"
y="103.68204"
id="text4-1-1"><tspan
sodipodi:role="line"
id="tspan4-1-2"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465"
x="22.457224"
y="103.68204">0</tspan></text>
<path
style="fill:#000000;fill-opacity:1;stroke:#dadada;stroke-width:0.3;stroke-dasharray:none;stroke-opacity:1"
d="M 40.710985,49.035087 V 65.438055"
id="path5" />
<path
style="fill:#000000;fill-opacity:1;stroke:#dadada;stroke-width:0.3;stroke-dasharray:none;stroke-opacity:1"
d="m 60.546682,57.469645 v 7.773293"
id="path5-9" />
<path
style="fill:#000000;fill-opacity:1;stroke:#dadada;stroke-width:0.3;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#Triangle);marker-end:url(#Triangle)"
d="M 58.396033,62.880678 H 42.878103"
id="path5-0"
sodipodi:nodetypes="cc" />
<text
xml:space="preserve"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1"
x="48.511959"
y="61.409679"
id="text4-1-1-5"><tspan
sodipodi:role="line"
id="tspan4-1-2-2"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465"
x="48.511959"
y="61.409679">Δz</tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465;stroke-dasharray:none;stroke-opacity:1"
x="-72.630066"
y="21.160755"
id="text4-9"
transform="rotate(-90)"><tspan
sodipodi:role="line"
id="tspan4-5"
style="font-size:4.23333px;fill:#dedede;fill-opacity:1;stroke:none;stroke-width:0.465"
x="-72.630066"
y="21.160755">Current</tspan></text>
<path
style="fill:#000000;fill-opacity:1;stroke:#dadada;stroke-width:0.3;stroke-dasharray:1.2, 0.3;stroke-dashoffset:0;stroke-opacity:1"
d="M 94.728796,67.061137 V 97.890816"
id="path6" />
<path
style="fill:#000000;fill-opacity:1;stroke:#dadada;stroke-width:0.3;stroke-dasharray:1.2, 0.3;stroke-dashoffset:0;stroke-opacity:1"
d="M 133.2181,76.566042 V 97.753468"
id="path6-1"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -17,7 +17,7 @@ author = 'Nicolas Kruse'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_autodoc_typehints", "myst_parser"]
extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_autodoc_typehints", "myst_parser", "sphinx.ext.autosummary"]
templates_path = ['_templates']
exclude_patterns = []
@ -26,7 +26,8 @@ exclude_patterns = []
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
# html_theme = 'alabaster'
html_theme = 'sphinx_rtd_theme'
html_theme = 'pydata_sphinx_theme'
html_static_path = ['_static']
autodoc_inherit_docstrings = True
autoclass_content = 'both'

View File

@ -0,0 +1,5 @@
# gaspype.FloatArray
```{eval-rst}
.. autoclass:: gaspype.FloatArray
```

View File

@ -1,11 +1,17 @@
# This script generates the source md-files for all classes and functions for the docs
import importlib
import inspect
import fnmatch
from io import TextIOWrapper
def write_classes(f: TextIOWrapper, patterns: list[str], module_name: str, title: str, description: str = '', exclude: list[str] = []) -> None:
def write_manual(f: TextIOWrapper, doc_files: list[str], title: str) -> None:
write_dochtree(f, title, doc_files)
def write_classes(f: TextIOWrapper, patterns: list[str], module_name: str, title: str, description: str = '', exclude: list[str] = []) -> None:
"""Write the classes to the file."""
module = importlib.import_module(module_name)
classes = [
@ -15,26 +21,25 @@ def write_classes(f: TextIOWrapper, patterns: list[str], module_name: str, title
obj.__doc__ and '(Automatic generated stub)' not in obj.__doc__)
]
"""Write the classes to the file."""
f.write(f'## {title}\n\n')
if description:
f.write(f'{description}\n\n')
write_dochtree(f, title, classes)
for cls in classes:
f.write('```{eval-rst}\n')
f.write(f'.. autoclass:: {module_name}.{cls}\n')
f.write(' :members:\n')
f.write(' :class-doc-from: both\n')
f.write(' :undoc-members:\n')
f.write(' :show-inheritance:\n')
f.write(' :inherited-members:\n')
if title != 'Base classes':
f.write(' :exclude-members: select\n')
f.write('```\n\n')
with open(f'docs/source/_autogenerated/{cls}.md', 'w') as f2:
f2.write(f'# {module_name}.{cls}\n')
f2.write('```{eval-rst}\n')
f2.write(f'.. autoclass:: {module_name}.{cls}\n')
f2.write(' :members:\n')
f2.write(' :undoc-members:\n')
f2.write(' :show-inheritance:\n')
f2.write(' :inherited-members:\n')
f2.write('```\n\n')
def write_functions(f: TextIOWrapper, patterns: list[str], module_name: str, title: str, description: str = '', exclude: list[str] = []) -> None:
"""Write the classes to the file."""
module = importlib.import_module(module_name)
functions = [
@ -43,19 +48,37 @@ def write_functions(f: TextIOWrapper, patterns: list[str], module_name: str, tit
any(fnmatch.fnmatch(name, pat) for pat in patterns if pat not in exclude))
]
"""Write the classes to the file."""
f.write(f'## {title}\n\n')
if description:
f.write(f'{description}\n\n')
write_dochtree(f, title, functions)
for func in functions:
if not func.startswith('_'):
f.write('```{eval-rst}\n')
f.write(f'.. autofunction:: {module_name}.{func}\n')
f.write('```\n\n')
with open(f'docs/source/_autogenerated/{func}.md', 'w') as f2:
f2.write(f'# {module_name}.{func}\n')
f2.write('```{eval-rst}\n')
f2.write(f'.. autofunction:: {module_name}.{func}\n')
f2.write('```\n\n')
with open('docs/source/modules.md', 'w') as f:
f.write('# Functions and classes\n\n')
write_classes(f, ['*'], 'gaspype', title='Classes')
write_functions(f, ['*'], 'gaspype', title='Functions')
def write_dochtree(f: TextIOWrapper, title: str, items: list[str]):
f.write('```{toctree}\n')
f.write(':maxdepth: 1\n')
f.write(f':caption: {title}:\n')
#f.write(':hidden:\n')
for text in items:
if not text.startswith('_'):
f.write(f"{text}\n")
f.write('```\n\n')
if __name__ == "__main__":
with open('docs/source/_autogenerated/index.md', 'w') as f:
f.write('# Classes and functions\n\n')
write_classes(f, ['*'], 'gaspype', title='Classes')
write_functions(f, ['*'], 'gaspype', title='Functions')
write_manual(f, ['../ndfloat', '../floatarray'], title='Types')

View File

@ -1,9 +1,8 @@
```{toctree}
:maxdepth: 2
:caption: Contents:
readme
modules
:maxdepth: 1
:hidden:
_autogenerated/index
_autogenerated/examples
```
```{include} ../../README.md

5
docs/source/ndfloat.md Normal file
View File

@ -0,0 +1,5 @@
# gaspype.NDFloat
```{eval-rst}
.. autoclass:: gaspype.NDFloat
```

View File

@ -1,2 +0,0 @@
```{include} ../../README.md
```

View File

@ -0,0 +1,66 @@
# This script converts the example md-files as jupyter notebook,
# execute the notebook and convert the notebook back to a md-file
# with outputs included.
import subprocess
from glob import glob
import os
from io import TextIOWrapper
def run_cmd(command: list[str]):
result = subprocess.run(command, capture_output=True, text=True)
print('> ' + ' '.join(command))
print(result.stdout)
assert (not result.stderr or
any('RuntimeWarning: ' in line for line in result.stderr.splitlines()) or
any('[NbConvertApp]' in line and 'error' not in line.lower() for line in result.stderr.splitlines())), 'ERROR: ' + result.stderr
def run_rendering(input_path: str, output_directory: str):
file_name = '.'.join(os.path.basename(input_path).split('.')[:-1])
assert file_name
print(f'- Convert {input_path} ...')
run_cmd(['notedown', input_path, '--to', 'notebook', '--output', f'{output_directory}/{file_name}.ipynb', '--run'])
run_cmd(['jupyter', 'nbconvert', '--to', 'markdown', f'{output_directory}/{file_name}.ipynb', '--output', f'{file_name}.md'])
run_cmd(['python', 'tests/md_to_code.py', 'script', f'{input_path}', f'{output_directory}/{file_name}.py'])
def write_dochtree(f: TextIOWrapper, title: str, items: list[str]):
f.write('```{toctree}\n')
f.write(':maxdepth: 1\n')
#f.write(':hidden:\n')
#f.write(f':caption: {title}:\n')
for text in items:
if not text.startswith('_'):
f.write(f"{text}\n")
f.write('```\n\n')
def render_examples(filter: str, example_file: str):
files = glob(filter)
names = ['.'.join(os.path.basename(path).split('.')[:-1]) for path in files]
with open(example_file, 'w') as f:
f.write('# Gaspype examples\n\n')
write_dochtree(f, '', [n for n in names if n.lower() != 'readme'])
f.write('## Download Jupyter Notebooks\n\n')
for path, name in zip(files, names):
if name.lower() != 'readme':
run_rendering(path, 'docs/source/_autogenerated')
notebook = name + '.ipynb'
f.write(f'- [{notebook}]({notebook})\n\n')
f.write('## Download plain python files\n\n')
for path, name in zip(files, names):
if name.lower() != 'readme':
script_name = name + '.py'
f.write(f'- [{script_name}]({script_name})\n\n')
if __name__ == "__main__":
render_examples('examples/*.md', 'docs/source/_autogenerated/examples.md')

25
examples/README.md Normal file
View File

@ -0,0 +1,25 @@
# Example scripts
Examples can be looked-up in the
[documentation](https://dlr-institute-of-future-fuels.github.io/gaspype/)
rendered with results.
The gaspype examples from this directory are available in the documentation as
downloadable Jupyter Notebooks or plain python scripts with comments.
The conversion is done like the following automated by the
[docs/source/render_examples.py](../docs/source/render_examples.py) script:
``` bash
# Converting markdown with code sections to Jupyter Notebook and run it:
notedown examples/soec_methane.md --to notebook --output docs/source/_autogenerated/soec_methane.ipynb --run
# Converting the Jupyter Notebook to Markdown and a folder with image
# files placed in docs/source/_autogenerated/:
jupyter nbconvert --to markdown docs/source/_autogenerated/soec_methane.ipynb --output soec_methane.md
```
A new example Markdown file can be created from a Jupyter Notebook running
the following command:
``` bash
jupyter nbconvert --to markdown new_example.ipynb --NbConvertApp.use_output_suffix=False --ClearOutputPreprocessor.enabled=True --output-dir examples/ --output new_example.md
```

View File

@ -0,0 +1,68 @@
# Carbon Activity
This example shows the equilibrium calculation for solid carbon.
```python
import gaspype as gp
import numpy as np
import matplotlib.pyplot as plt
```
Setting temperatures and pressure:
```python
t_range = np.array([600, 700, 800, 900, 1100, 1500]) # °C
p = 1e5 # Pa
fs = gp.fluid_system(['H2', 'H2O', 'CO2', 'CO', 'CH4'])
```
Equilibrium calculation for methane steam mixtures:
```python
ratio = np.linspace(0.01, 1.5, num=128)
fl = gp.fluid({'CH4': 1}, fs) + ratio * gp.fluid({'H2O': 1}, fs)
```
gaspype.carbon_activity supports currently only 0D fluids therefore we build this helper function:
```python
def partial_c_activity(fl: gp.fluid, t: float, p: float):
fls = fl.array_composition.shape
eq_fl = gp.equilibrium(fl, t, p)
ret = np.zeros(fls[0])
for i in range(fls[0]):
ret[i] = gp.carbon_activity(gp.fluid(eq_fl.array_composition[i,:], fs), t, p)
return ret
```
Now we use the helper function to calculate the carbon activitx for all
compositions in equilibrium_h2o times all temperatures in t_range:
```python
carbon_activity = np.vstack([partial_c_activity(fl, tc + 273.15, p) for tc in t_range])
```
Plot carbon activities, a activity of > 1 means there is thermodynamically the formation of sold carbon favored.
```python
fig, ax = plt.subplots(figsize=(6, 4), dpi=120)
ax.set_xlabel("CO2/CH4")
ax.set_ylabel("carbon activity")
ax.set_ylim(1e-1, 1e3)
ax.set_yscale('log')
ax.plot(ratio, carbon_activity.T)
ax.hlines(1, np.min(ratio), np.max(ratio), colors='k', linestyles='dashed')
ax.legend([f'{tc} °C' for tc in t_range])
```

View File

@ -0,0 +1,60 @@
# Methane Mixtures
This example shows equilibria of methane mixed with steam and CO2
```python
import gaspype as gp
import numpy as np
import matplotlib.pyplot as plt
```
Setting temperature and pressure:
```python
t = 900 + 273.15
p = 1e5
fs = gp.fluid_system(['H2', 'H2O', 'CO2', 'CO', 'CH4', 'O2'])
```
Equilibrium calculation for methane steam mixtures:
```python
ratio = np.linspace(0.01, 1.5, num=64)
el = gp.fluid({'CH4': 1}, fs) + ratio * gp.fluid({'H2O': 1}, fs)
equilibrium_h2o = gp.equilibrium(el, t, p)
```
```python
fig, ax = plt.subplots(figsize=(6, 4), dpi=120)
ax.set_xlabel("H2O/CH4")
ax.set_ylabel("molar fraction")
ax.set_ylim(0, 1.1)
#ax.set_xlim(0, 100)
ax.plot(ratio, equilibrium_h2o.get_x())
ax.legend(fs.active_species)
```
Equilibrium calculation for methane CO2 mixtures:
```python
el = gp.fluid({'CH4': 1}, fs) + ratio * gp.fluid({'H2O': 1}, fs)
equilibrium_co2 = gp.equilibrium(el, t, p)
```
```python
fig, ax = plt.subplots(figsize=(6, 4), dpi=120)
ax.set_xlabel("CO2/CH4")
ax.set_ylabel("molar fraction")
ax.set_ylim(0, 1.1)
#ax.set_xlim(0, 100)
ax.plot(ratio, equilibrium_co2.get_x())
ax.legend(fs.active_species)
```

115
examples/soec_methane.md Normal file
View File

@ -0,0 +1,115 @@
# SOEC with Methane
This example shows a 1D isothermal SOEC (Solid oxide electrolyzer cell) model.
The operating parameters chosen here are not necessary realistic
```python
import gaspype as gp
from gaspype import R, F
import numpy as np
import matplotlib.pyplot as plt
```
Calculation of the local equilibrium compositions on the fuel and air
side in counter flow along the fuel flow direction:
```python
fuel_utilization = 0.90
air_utilization = 0.5
t = 800 + 273.15 #K
p = 1e5 #Pa
fs = gp.fluid_system('H2, H2O, O2, CH4, CO, CO2')
feed_fuel = gp.fluid({'CH4': 1, 'H2O': 0.1}, fs)
o2_full_conv = np.sum(gp.elements(feed_fuel)[['H', 'C' ,'O']] * [1/4, 1, -1/2])
feed_air = gp.fluid({'O2': 1, 'N2': 4}) * o2_full_conv / air_utilization
conversion = np.linspace(0, fuel_utilization, 32)
perm_oxygen = o2_full_conv * conversion * gp.fluid({'O2': 1})
fuel_side = gp.equilibrium(feed_fuel + perm_oxygen, t, p)
air_side = gp.equilibrium(feed_air - perm_oxygen, t, p)
```
Plot compositions of the fuel and air side:
```python
fig, ax = plt.subplots()
ax.set_xlabel("Conversion")
ax.set_ylabel("Molar fraction")
ax.plot(conversion, fuel_side.get_x(), '-')
ax.legend(fuel_side.species)
fig, ax = plt.subplots()
ax.set_xlabel("Conversion")
ax.set_ylabel("Molar fraction")
ax.plot(conversion, air_side.get_x(), '-')
ax.legend(air_side.species)
```
Calculation of the oxygen partial pressures:
```python
o2_fuel_side = gp.oxygen_partial_pressure(fuel_side, t, p)
o2_air_side = air_side.get_x('O2') * p
```
Plot oxygen partial pressures:
```python
fig, ax = plt.subplots()
ax.set_xlabel("Conversion")
ax.set_ylabel("Oxygen partial pressure / Pa")
ax.set_yscale('log')
ax.plot(conversion, np.stack([o2_fuel_side, o2_air_side], axis=1), '-')
ax.legend(['o2_fuel_side', 'o2_air_side'])
```
Calculation of the local nernst potential between fuel and air side:
```python
z_O2 = 4
nernst_voltage = R*t / (z_O2*F) * np.log(o2_air_side/o2_fuel_side)
```
#Plot nernst potential:
```python
fig, ax = plt.subplots()
ax.set_xlabel("Conversion")
ax.set_ylabel("Voltage / V")
ax.plot(conversion, nernst_voltage, '-')
print(np.min(nernst_voltage))
```
The model uses between each node a constant conversion. Because
current density depends strongly on the position along the cell
the constant conversion does not relate to a constant distance.
![Alt text](../../media/soc_inverted.svg)
To calculate the local current density (**node_current**) as well
as the total cell current (**terminal_current**) the (relative)
physical distance between the nodes (**dz**) must be calculated:
```python
cell_voltage = 0.77 #V
ASR = 0.2 #Ohm*cm²
node_current = (nernst_voltage - cell_voltage) / ASR # mA/cm² (Current density at each node)
current = (node_current[1:] + node_current[:-1]) / 2 # mA/cm² (Average current density between the nodes)
dz = 1/current / np.sum(1/current) # Relative distance between each node
terminal_current = np.sum(current * dz) # mA/cm² (Total cell current per cell area)
print(f'Terminal current: {terminal_current:.2f} A/cm²')
```
Plot the local current density:
```python
z_position = np.concatenate([[0], np.cumsum(dz)]) # Relative position of each node
fig, ax = plt.subplots()
ax.set_xlabel("Relative cell position")
ax.set_ylabel("Current density / A/cm²")
ax.plot(z_position, node_current, '-')
```

View File

@ -0,0 +1,52 @@
# Sulfur Oxygen Equilibrium
This example shows equilibrium calculations for sulfur/oxygen mixtures.
```python
import gaspype as gp
import numpy as np
import matplotlib.pyplot as plt
```
List possible sulfur/oxygen species:
```python
gp.species(element_names = 'S, O')
```
Or more specific by using regular expressions:
```python
gp.species('S?[2-3]?O?[2-5]?', use_regex=True)
```
Calculation of the molar equilibrium fractions for sulfur and oxygen depending on the oxygen to sulfur ratio:
```python
fs = gp.fluid_system(['S2', 'S2O', 'SO2', 'SO3', 'O2'])
oxygen_ratio = np.linspace(0.5, 3, num=128)
el = gp.elements({'S': 1}, fs) + oxygen_ratio * gp.elements({'O': 1}, fs)
composition = gp.equilibrium(el, 800+273.15, 1e4)
plt.plot(oxygen_ratio, composition.get_x())
plt.legend(composition.species)
```
Calculation of the molar equilibrium fractions for sulfur and oxygen depending on temperature in °C:
```python
fs = gp.fluid_system(['S2', 'S2O', 'SO2', 'SO3', 'O2'])
el = gp.elements({'S': 1, 'O':2.5}, fs)
t_range = np.linspace(500, 1300, num=32)
composition = gp.equilibrium(el, t_range+273.15, 1e4)
plt.plot(t_range, composition.get_x())
plt.legend(composition.species)
```

View File

@ -41,7 +41,20 @@ dev = [
"cantera",
"pyyaml>=6.0.1",
"types-PyYAML",
"scipy-stubs"
"scipy-stubs",
"matplotlib"
]
doc_build = [
"sphinx",
"pydata_sphinx_theme",
"sphinx-autodoc-typehints",
"myst-parser",
"pandas",
"matplotlib",
"ipykernel",
"jupyter",
"nbconvert",
"notedown"
]
[tool.mypy]

View File

@ -29,9 +29,9 @@ p_atm = 101325 # Pa
_epsy = 1e-18
def lookup(prop_array: FloatArray,
temperature: FloatArray | float,
t_offset: float) -> FloatArray:
def _lookup(prop_array: FloatArray,
temperature: FloatArray | float,
t_offset: float) -> FloatArray:
"""linear interpolates values from the given prop_array
Args:
@ -58,9 +58,9 @@ def species(pattern: str = '*', element_names: str | list[str] = [], use_regex:
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
# 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.
@ -95,7 +95,17 @@ def species(pattern: str = '*', element_names: str | list[str] = [], use_regex:
def set_solver(solver: Literal['gibs minimization', 'system of equations']) -> None:
"""Select a solver for chemical equilibrium.
"""
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
@ -127,10 +137,10 @@ class fluid_system:
Attributes:
species_names (list[str]): List of selected species in the fluid_system
array_molar_mass: Array of the molar masses of the species in the fluid_system
array_element_composition: Array of the element composition of the 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: Array of the atomic masses of the elements in the fluid_system
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):
@ -202,7 +212,7 @@ class fluid_system:
Returns:
Array with the enthalpies of each specie in J/mol
"""
return lookup(self._h_array, t, self._t_offset)
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
@ -213,7 +223,7 @@ class fluid_system:
Returns:
Array with the entropies of each specie in J/mol/K
"""
return lookup(self._s_array, t, self._t_offset)
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
@ -224,7 +234,7 @@ class fluid_system:
Returns:
Array with the heat capacities of each specie in J/mol/K
"""
return lookup(self._cp_array, t, self._t_offset)
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)
@ -239,7 +249,7 @@ class fluid_system:
Returns:
Array of gibbs free energy divided by RT (dimensionless)
"""
return lookup(self._g_rt_array, t, self._t_offset)
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
@ -263,13 +273,12 @@ class fluid:
one or more species.
Attributes:
fs: Reference to the fluid_system used for this fluid
species: List of species names in the associated fluid_system
array_composition: Array of the molar amounts of the species in the fluid
array_element_composition: Array of the element composition in the fluid
array_fractions: Array of the molar fractions of the species in the fluid
total: Array of the sums of the molar amount of all species
fs: Reference to the fluid_system used for this fluid
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
@ -606,7 +615,7 @@ class elements:
"""Represent a fluid by composition of elements.
Attributes:
array_element_composition: Array of the element composition
array_element_composition (FloatArray): Array of the element composition
"""
__array_priority__ = 100

128
tests/md_to_code.py Normal file
View File

@ -0,0 +1,128 @@
import re
from typing import Generator, Iterable
from dataclasses import dataclass
import sys
@dataclass
class markdown_segment:
code_block: bool
language: str
text: str
def convert_to(target_format: str, md_filename: str, out_filename: str, language: str = 'python'):
with open(md_filename, "r") as f_in, open(out_filename, "w") as f_out:
segments = segment_markdown(f_in)
if target_format == 'test':
f_out.write('\n'.join(segments_to_test(segments, language)))
elif target_format == 'script':
f_out.write('\n'.join(segments_to_script(segments, language)))
elif target_format == 'striped_markdown':
f_out.write('\n'.join(segments_to_striped_markdown(segments, language)))
else:
raise ValueError('Unknown target format')
def segment_markdown(markdown_file: Iterable[str]) -> Generator[markdown_segment, None, None]:
regex = re.compile(r"(?:^```\s*(?P<language>(?:\w|-)*)$)", re.MULTILINE)
block_language: str = ''
code_block = False
line_buffer: list[str] = []
for line in markdown_file:
match = regex.match(line)
if match:
if line_buffer:
yield markdown_segment(code_block, block_language, ''.join(line_buffer))
line_buffer.clear()
block_language = match.group('language')
code_block = not code_block
else:
line_buffer.append(line)
if line_buffer:
yield markdown_segment(code_block, block_language, '\n'.join(line_buffer))
def segments_to_script(segments: Iterable[markdown_segment], test_language: str = "python") -> Generator[str, None, None]:
for segment in segments:
if segment.code_block:
if segment.language == test_language:
yield segment.text
else:
for line in segment.text.splitlines():
yield '# | ' + line
yield ''
else:
for line in segment.text.strip(' \n').splitlines():
yield '# ' + line
yield ''
def segments_to_striped_markdown(segments: Iterable[markdown_segment], test_language: str = "python") -> Generator[str, None, None]:
for segment in segments:
if segment.code_block:
if segment.language == test_language:
yield "``` " + test_language
yield segment.text
yield "```"
elif segment.language:
for line in segment.text.splitlines():
yield '# | ' + line
yield ''
else:
for line in segment.text.strip(' \n').splitlines():
yield '# ' + line
yield ''
def segments_to_test(segments: Iterable[markdown_segment], script_language: str = "python") -> Generator[str, None, None]:
ret_block_flag = False
yield 'def run_test():'
for segment in segments:
if segment.code_block:
if segment.language == script_language:
lines = [line for line in segment.text.splitlines() if line.strip()]
ret_block_flag = lines[-1] if (not re.match(r'^[^(]*=', lines[-1]) and
not lines[-1].startswith('import ') and
not lines[-1].startswith('from ') and
not lines[-1].startswith(' ')) else None
# print('Last line: ', ret_block_flag, '-----------', lines[-1])
yield ''
yield ' print("---------------------------------------------------------")'
yield ''
if ret_block_flag:
yield from [' ' + str(line) for line in segment.text.splitlines()[:-1]]
yield f' print("-- Result (({ret_block_flag})):")'
yield f' print(({ret_block_flag}).__repr__().strip())'
else:
yield from [' ' + str(line) for line in segment.text.splitlines()]
elif ret_block_flag:
yield ' ref_str = r"""'
yield from [str(line) for line in segment.text.splitlines()]
yield '"""'
yield f' print("-- Reference (({ret_block_flag})):")'
yield ' print(ref_str.strip())'
yield f' assert ({ret_block_flag}).__repr__().strip() == ref_str.strip()'
ret_block_flag = False
yield '\nif __name__ == "__main__":'
yield ' run_test()'
if __name__ == "__main__":
format = sys.argv[1]
assert format in ['test', 'script']
convert_to(sys.argv[1], sys.argv[2], sys.argv[3])

View File

@ -1,53 +1,28 @@
import re
from typing import Generator
def convert_markdown_file(md_filename: str, out_filename: str):
with open(md_filename, "r") as f_in:
with open(out_filename, "w") as f_out:
f_out.write('def run_test():\n')
for block in markdown_to_code([line for line in f_in]):
f_out.write(block + '\n')
def markdown_to_code(lines: list[str], language: str = "python") -> Generator[str, None, None]:
regex = re.compile(
r"(?P<start>^```\s*(?P<block_language>(\w|-)*)\n)(?P<code>.*?\n)(?P<end>```)",
re.DOTALL | re.MULTILINE,
)
blocks = [
(match.group("block_language"), match.group("code"))
for match in regex.finditer("".join(lines))
]
ret_block_flag = False
for block_language, block in blocks:
if block_language == language:
lines = [line for line in block.splitlines() if line.strip()]
ret_block_flag = lines[-1] if '=' not in lines[-1] else None
yield ''
yield ' print("---------------------------------------------------------")'
yield ''
if ret_block_flag:
yield from [' ' + str(line) for line in block.splitlines()[:-1]]
else:
yield from [' ' + str(line) for line in block.splitlines()]
yield f' print("-- Result (({ret_block_flag})):")'
yield f' print(({ret_block_flag}).__repr__().strip())'
elif ret_block_flag:
yield ' ref_str = r"""'
yield from [str(line) for line in block.splitlines()]
yield '"""'
yield f' print("-- Reference (({ret_block_flag})):")'
yield ' print(ref_str.strip())'
yield f' assert ({ret_block_flag}).__repr__().strip() == ref_str.strip()'
ret_block_flag = False
import md_to_code
from glob import glob
import importlib
import os
def test_readme():
convert_markdown_file('README.md', 'tests/autogenerated_readme.py')
md_to_code.convert_to('test', 'README.md', 'tests/autogenerated_readme.py')
import autogenerated_readme
autogenerated_readme.run_test()
def test_example_code():
filter = 'examples/*.md'
files = glob(filter)
for path in files:
file_name = '.'.join(os.path.basename(path).split('.')[:-1])
if not file_name.lower() == 'readme':
print(f"> Test Example {file_name} ...")
md_to_code.convert_to('test', path, f'tests/autogenerated_{file_name}.py')
mod = importlib.import_module(f'autogenerated_{file_name}')
mod.run_test()
if __name__ == "__main__":
test_readme()
test_example_code()