Generated Julia API#
Caution
The generated Julia API is an experimental part of Cantera and may be changed or removed without notice.
The Julia API is written in Julia and supports Julia 1.9 (and newer) on all platforms
that support both Julia and the Cantera C++ library. It calls libcantera directly
through the CLib API using Julia’s built-in ccall.
The Julia API implementation draws on two parts:
Julia Code Generation, which translates the CLib specifications into corresponding
ccallwrappers.The Julia API included in
interfaces/julia, which contains hand-coded API wrappers that extend generated code to form the actual user interface.
Building the Julia Interface#
The generated bindings are scaffolded as part of the regular build, so building the main Cantera library with default options is sufficient:
scons build
Because the bindings are scaffolded from the Doxygen tag file that sourcegen parses (see Automated Code Generation), Doxygen must be installed even when no documentation is being built.
The Julia package is not registered in Julia’s General registry, so it is used from the source tree by activating its project. Julia locates the shared library and the mechanism files through two environment variables:
export CANTERA_LIBRARY_PATH=/path/to/cantera/lib # or a conda environment
export CANTERA_DATA=/path/to/cantera/data # for gri30.yaml and friends
Neither is required when Cantera was built in-tree: the interface falls back to the
sibling build/lib and data directories of the source checkout.
The dependencies are instantiated, and the test suite is invoked, by running
julia --project=interfaces/julia -e 'using Pkg; Pkg.instantiate()'
julia --project=interfaces/julia interfaces/julia/test/runtests.jl
The Julia Interface Documentation pages are generated from the docstrings in
interfaces/julia/src by interfaces/julia/docs/generate.jl, which doc/SConscript
runs as part of
scons sphinx
The script writes one MyST page per topic into build/doc/sphinx/julia, describing
each object with the standard describe directive. Sphinx has no Julia domain:
sphinx.ext.autodoc handles only Python, and the one third-party attempt at a Julia
domain has been unmaintained since 2017, so the reference deliberately uses a
domain-neutral directive rather than adding an extension. Each entry is preceded by a
MyST target so that it can still be linked to; what is given up relative to a real
domain is the general index and the # permalink beside each object.
Loading the Cantera module to read its docstrings requires the generated CLib
bindings, and therefore a previous scons build; it does not require a working
libcantera, because every ccall sits inside a function body and is not resolved at
load time.
Julia is not required to build the documentation. If the julia command is not found
on the PATH, the reference pages are skipped and Julia Interface Documentation carries a note in
place of them; the rest of the Julia documentation is unaffected. Use julia_docs=y to
turn a missing Julia installation into an error, as the workflow that publishes the
documentation does, or julia_docs=n to skip the pages even where Julia is available.
The list of pages and the toctree that refers to them are written by generate.jl into
julia/api-reference.md, which julia/index.md includes, so that the index never
refers to a page that was not generated.
Docstrings are written in Markdown, as Julia expects, and the pages are MyST, so the prose of a docstring is emitted as-is. Two things are handled specially:
Documenter’s
[`name`](@ref)cross-references are rewritten to point at the target of the object they name, falling back to literal text for a name that is not documented.Signatures are separated from the prose. Julia allows one docstring per method, and the interface uses that for names whose meaning depends on the argument type —
temperatureis documented once for a phase, once for a mixture and once for a solution array. All of them are rendered under a single entry: the first signature becomes the argument of thedescribedirective, and every other call form is reintroduced in the body as ajuliablock ahead of the prose belonging to it, so that no documented meaning is lost. A docstring that carries no signature block of its own is labelled from the method it is attached to, which is why the signature and the prose beneath it always describe the same method.
Note that every exported object must carry a docstring: an object without one is silently absent from the reference.
Labels are built to survive Sphinx’s normalisation, which lowercases them, replaces _
with -, and discards characters such as !. They therefore include the kind of object
and spell out the ! of an in-place variant, so that foo and foo!, or the type
Reaction and the function reaction, do not collide. The generator applies that
normalisation itself, so the label it writes is the identifier that appears in the
rendered page, and it fails if two objects ever produce the same label.
The generator also fails the build if a new file is added under interfaces/julia/src
without being assigned to one of the topic pages, rather than dropping its contents
from the documentation.
Julia Code Generation#
Source generation for the Julia interface is fully integrated into the build process by
interfaces/julia/SConscript. Julia files used by the Julia API can be generated for
informational purposes by running the following command from the root folder of the
Cantera source code:
sourcegen --api=julia --output=build/julia
Generated Julia files are placed in the src/generated subfolder of the output folder,
that is, build/julia/src/generated. Note that this step requires installation of
sourcegen via python -m pip install -e interfaces/sourcegen.
The generator emits one file per CLib specification — libctthermo.jl for
ctthermo.yaml, and so on — plus a _manifest.jl listing them, which is what
LibCantera.jl iterates over to include the bindings. Because the file names follow
from the specifications, SConscript can declare them as build targets without running
the generator first.
Two aspects of the generator are worth noting for developers:
The output directory is emptied on every run. A binding file for a specification that is no longer generated would otherwise linger and shadow the current CLib.
Generated files are not committed to the repository, since they can be recreated on demand. A checkout that has never been built therefore has no
src/generateddirectory, andusing Canterafails untilscons buildhas run.
Like the other sourcegen sub-packages, the Julia generator is configured by
interfaces/sourcegen/src/sourcegen/julia/config.yaml. In addition to the standard
ignore_files and ignore_funcs keys described in Details, it
defines a c_type_crosswalk mapping each C type used by the CLib to the Julia type used
in the ccall signature, for example double* to Ptr{Float64}. Generation aborts
with an error naming the offending type if the CLib introduces a type that has no entry,
rather than emitting a binding that would be wrong at the ABI level.
Julia Interface Overview#
The public API is a conventional Julia package rooted at interfaces/julia/src, with
Cantera.jl as the module entry point. Its structure follows Cantera’s C++ class
hierarchy rather than the flat CLib namespace:
LibCantera.jlInternal module: locates
libcanteraand includes the generated bindings.errors.jlTranslation of CLib error sentinels into
CanteraErrorexceptions.handles.jlWrapper supertype, phase views, and string/array marshalling helpers.
solution.jlSolution, the primary user-facing object.thermo.jl,kinetics.jl,transport.jlProperty accessors for the three managers of a phase.
reaction.jl,func1.jlReactionandFunc1objects.reactor.jl,reactornet.jl,connectors.jlZero-dimensional reactor networks.
onedim.jlOne-dimensional flames.
multiphase.jl,rdiag.jl,solutionarray.jlMultiphase equilibrium, reaction-path diagrams, and collections of states.
utils.jlPhysical constants and library-level functions.
Public names are exported from the file that defines them, so adding a method requires touching only one file.
Implementation Notes#
The following are the aspects of the interface that are least obvious from reading the source, and the ones most likely to be disturbed by a well-intentioned change.
Library discovery happens at run time, not precompile time#
LibCantera.libcantera is a Ref{String} that is assigned in the module’s __init__
function, and the generated wrappers dereference it as libcantera[] on every call.
This indirection is deliberate. Julia caches a precompiled image of the module, so a
library path resolved at precompile time would be frozen into that image, and a later
change to CANTERA_LIBRARY_PATH would be silently ignored. Resolving the path in
__init__ keeps it correct for the session that actually runs.
Only the source tree’s own data directory is registered by Cantera.__init__.
CANTERA_DATA and the conda share/cantera/data directory are already added by
Cantera’s Application::setDefaultDirectories before any input file is read, and adding
them again from Julia would be redundant.
Errors are sentinel values, not exceptions#
The CLib cannot propagate C++ exceptions, so it signals failure through return values:
functions returning int32_t return a negative value, and functions returning double
return DERR (-999.999). Every call therefore passes through check (for integers) or
checkd (for doubles), which retrieve the message with ct_getCanteraError and throw a
CanteraError. A new hand-written wrapper that omits these will report failures as
implausible numbers rather than as errors.
The one complication is that a negative int32_t is not always an error: CLib functions
that look up a name return a negative npos value when the name is absent. Wrappers
such as species_index must therefore test for that case before calling check.
Strings and arrays use a size-query protocol#
CLib string getters are called twice: once with a null buffer to obtain the required
length, and again with a buffer of that size. get_string encapsulates this, taking a
closure so that any getter’s leading arguments can be bound at the call site.
Array getters take a caller-allocated buffer, filled by get_array! or by get_array,
which allocates first. Most array properties expose both a normal and an in-place
foo!(out, gas) variant, the latter to avoid an allocation per call in loops.
Non-Float64 input vectors are converted at the boundary by as_f64, since a ccall
needs a contiguous Vector{Float64}.
Handle lifetime is the interface’s main hazard#
Every CLib object is an Int32 handle into a C++-side cabinet, and Julia’s garbage
collector knows nothing about the object it refers to. Each wrapper is therefore a
mutable struct with a finalizer that calls the matching *_del function through an
idempotent close!, guarded by a closed flag. These close! methods deliberately
swallow exceptions: they run on the finalizer path, where throwing is not useful.
Two consequences shape the type definitions:
The
ThermoPhase,KineticsandTransportviews do not own their handles — they borrow them from aSolution. Each is parametrized on the type of its owner and stores a reference to it, so that a view keeps theSolutionalive even if the variable holding theSolutiongoes out of scope first. Without that reference, theSolutioncould be finalized while a view is still in use, leaving the view pointing at a deleted cabinet entry.Reactors clone the phase they are constructed from, as the C++ core expects. A reactor’s state is consequently not observable through the
Solutionpassed to its constructor — that object keeps its original state. The reactor’s own phase is reached withreactor_phase, and scalar reactor state has direct accessors such astemperature(reactor).
Not every phase has a kinetics or transport manager. Solution records a -1 for a
manager that could not be created and raises a descriptive CanteraError when one is
requested, rather than passing an invalid handle across the boundary.
Repetitive accessors are metaprogrammed#
Families of accessors that differ only in the CLib function they call — the partial
molar properties, for instance — are generated by @eval over a list of name/function
pairs. Docstrings do not survive this transformation automatically, so the loops carry
an explicit @doc for each generated method; a generated accessor without one is
invisible in the API reference.
One-dimensional flames replicate the Python solver’s staging#
solve! for a FreeFlame does not simply call sim1D_solve. It reproduces the staged
strategy of the Python interface’s Sim1D.solve(auto=True): solving on a sequence of
progressively finer uniform grids, re-seeding the initial guess at each stage,
attempting an energy-enabled solve and falling back to a fixed-temperature solve, then
running a refinement pass. It also reproduces the DomainTooNarrow check, doubling the
domain width when the temperature gradient at either edge is significant compared to the
average gradient. This staging is what makes ignition cases converge from a cold start,
and its absence is felt as solver failures rather than as wrong answers.
Extending the Julia API#
Like all of Cantera, we welcome contributions to the Julia interface. Contributors should review the general coding standards; Julia code in Cantera also observes the 88-character line length limit used across the other interfaces, and every hand-written source file carries the license preamble.
Adding a property to the interface usually means working from the bottom up:
If the underlying CLib function does not yet exist, add a recipe to the appropriate specification in
interfaces/sourcegen/src/sourcegen/headers, as described in Generated CLib API. The Julia binding then appears automatically on the next build.Add the hand-written wrapper to the corresponding
interfaces/julia/srcfile, usingcheck/checkdand the marshalling helpers rather than callingccalldirectly, and export it from that same file.Add a test to the matching
interfaces/julia/test/test_*.jlfile. Tests are written against the Python interface’s behavior;interfaces/julia/test/reference_values.mdrecords where the expected values come from.
Because the low-level bindings are generated, a missing wrapper is far more often a
missing CLib recipe than a missing Julia method. Running
sourcegen --api=julia --output=build/julia and inspecting the generated file is the
quickest way to confirm whether a function crosses the boundary at all.