diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bdfae9f8af..d705ac10a4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -157,11 +157,11 @@ jobs: run: | sudo apt update sudo apt install libboost-dev gfortran libomp-dev libomp5 \ - libopenblas-openmp-dev libhdf5-dev + libopenblas-openmp-dev libhdf5-dev doxygen graphviz - name: Upgrade pip run: python3 -m pip install -U pip setuptools wheel - name: Install Python dependencies - run: python3 -m pip install ruamel.yaml scons numpy cython pandas pytest + run: python3 -m pip install ruamel.yaml scons numpy cython pandas pytest Jinja2 pytest-xdist pytest-github-actions-annotate-failures pint graphviz - name: Build Cantera run: python3 `which scons` build env_vars=all @@ -177,6 +177,13 @@ jobs: run: | LD_LIBRARY_PATH=build/lib python3 -m pytest -raP -v -n auto --durations=50 test/python + - name: Generate clib_experimental + run: | + python3 `which scons` doxygen + python3 interfaces/sourcegen/run.py --api=clib --output=. + python3 `which scons` build clib_experimental=y + - name: Run googletests for clib_experimental + run: python3 `which scons` test-clib-experimental --debug=time macos-multiple-pythons: name: ${{ matrix.macos-version }} with Python ${{ matrix.python-version }} @@ -893,9 +900,16 @@ jobs: steps: - uses: actions/checkout@v4 name: Checkout the repository + - name: Override default Python (Windows) + # Other operating systems use default system Python 3 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: x64 + if: matrix.os == 'windows-2022' - name: Install Python dependencies - # Install for the default system Python 3, which is used by 'dotnet build' - run: python3 -m pip install ruamel.yaml Jinja2 + # Install Python dependencies, which are used by 'dotnet build' + run: python3 -m pip install ruamel.yaml Jinja2 typing-extensions - name: Install library dependencies with micromamba (Windows) uses: mamba-org/setup-micromamba@v1 with: diff --git a/SConstruct b/SConstruct index 7d910dd100..0c72d54d5b 100644 --- a/SConstruct +++ b/SConstruct @@ -134,6 +134,10 @@ if "clean" in COMMAND_LINE_TARGETS: remove_file(name) for name in Path("site_scons").glob("**/*.pyc"): remove_file(name) + for name in Path("include/cantera/clib_experimental").glob("*.h"): + remove_file(name) + for name in Path("src/clib_experimental").glob("*.cpp"): + remove_file(name) logger.status("Done removing output files.", print_level=False) @@ -364,6 +368,10 @@ config_options = [ "sphinx_docs", "Build HTML documentation for Cantera using Sphinx.", False), + BoolOption( + "clib_experimental", + "Build experimental CLib.", + False), BoolOption( "run_examples", """Run examples to generate plots and outputs for Sphinx Gallery. Disable to diff --git a/doc/doxygen/Doxyfile b/doc/doxygen/Doxyfile index e2f006a640..51034c8fde 100644 --- a/doc/doxygen/Doxyfile +++ b/doc/doxygen/Doxyfile @@ -1013,7 +1013,8 @@ RECURSIVE = YES # Note that relative paths are relative to the directory from which doxygen is # run. -EXCLUDE = include/cantera/ext +EXCLUDE = include/cantera/ext \ + include/cantera/clib_experimental # The EXCLUDE_SYMLINKS tag can be used to select whether or not files or # directories that are symbolic links (a Unix file system feature) are excluded diff --git a/include/cantera/clib_experimental/.gitignore b/include/cantera/clib_experimental/.gitignore new file mode 100644 index 0000000000..424c745c12 --- /dev/null +++ b/include/cantera/clib_experimental/.gitignore @@ -0,0 +1 @@ +*.h diff --git a/include/cantera/clib_experimental/README.md b/include/cantera/clib_experimental/README.md new file mode 100644 index 0000000000..17b4e3014b --- /dev/null +++ b/include/cantera/clib_experimental/README.md @@ -0,0 +1,25 @@ +# Cantera – Experimental CLib Interface + +This directory and the associated `src/clib_experimental` folder contain an +experimental re-implementation of Cantera's traditional CLib interface. + +## Code Generation + +Run the following command from the Cantera root folder: + +``` +scons doxygen +python3 interfaces/sourcegen/run.py --api=clib --output=. +scons build clib_experimental=y +``` + +A rudimentary test suite ensures that code performs as expected: + +``` +scons test-clib-experimental +``` + +## Status + +The experimental CLib Interface is in preview and still missing many features +needed for parity with the traditional CLib interface. diff --git a/interfaces/dotnet/Cantera/Cantera.csproj b/interfaces/dotnet/Cantera/Cantera.csproj index 17761cbdc6..37e361451a 100644 --- a/interfaces/dotnet/Cantera/Cantera.csproj +++ b/interfaces/dotnet/Cantera/Cantera.csproj @@ -45,7 +45,7 @@ $([MSBuild]::NormalizePath($(IntermediateOutputPath)/sourcegen/)) - diff --git a/interfaces/sourcegen/README.md b/interfaces/sourcegen/README.md index 3f3561578e..466b7c79a0 100644 --- a/interfaces/sourcegen/README.md +++ b/interfaces/sourcegen/README.md @@ -2,12 +2,11 @@ The `sourcegen.generate_source` function crudely parses the CLib header files and generates intermediate objects which represent the functions: * header file path -* funcs: list of - * return type (string) - * name (string) - * params: list of - * return type - * name +* funcs: list of `Func` objects containing + * return type (`string`) + * name (`string`) + * params: list of function arguments (`ArgList`) + * optional annotations `sourcegen.generate_source` then delegates the source generation to a language-specific sub-package. The sub-package creates the source files in a location appropriate to the build for that language’s build process. @@ -34,4 +33,3 @@ Each sub-package can contain a yaml-based config file named `config.yaml`. The c The config file may contain additional values for use by the language-specific sub-package. ## running from the command line - diff --git a/interfaces/sourcegen/run.py b/interfaces/sourcegen/run.py index c207ec87f3..aac4b0a731 100644 --- a/interfaces/sourcegen/run.py +++ b/interfaces/sourcegen/run.py @@ -2,6 +2,48 @@ # at https://cantera.org/license.txt for license and copyright information. import sys +import argparse +import textwrap + import sourcegen -sourcegen.generate_source(*sys.argv[1:]) +def main(argv=None): + parser = create_argparser() + if argv is None and len(sys.argv) < 2: + parser.print_help(sys.stderr) + sys.exit(1) + args = parser.parse_args(argv) + lang = args.api + output = args.output + verbose = args.verbose + sourcegen.generate_source(lang, output, verbose=verbose) + +def create_argparser(): + parser = argparse.ArgumentParser( + description=( + "Experimental source generator for creating Cantera interface code."), + epilog=textwrap.dedent( + """ + The **sourcegen** utility is invoked as follows:: + + python path/to/sourcegen/run.py --api=csharp --output=. + + where the relative path has to be provided as the utility is not installed. + Currently supported API options are: 'csharp', 'clib' and 'yaml'. + """), + formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "-v", "--verbose", action="store_true", default=False, + help="show additional logging output") + parser.add_argument( + "--api", default="", + help="language of generated Cantera API code") + parser.add_argument( + "--output", default="", + help="specifies the OUTPUT folder name") + + return parser + +if __name__ == "__main__": + main() diff --git a/interfaces/sourcegen/sourcegen/_HeaderFileParser.py b/interfaces/sourcegen/sourcegen/_HeaderFileParser.py index 073a10f423..64658856d2 100644 --- a/interfaces/sourcegen/sourcegen/_HeaderFileParser.py +++ b/interfaces/sourcegen/sourcegen/_HeaderFileParser.py @@ -1,4 +1,4 @@ -"""Parser for existing CLib headers.""" +"""Parser for YAML header configurations or existing CLib headers.""" # This file is part of Cantera. See License.txt in the top-level directory or # at https://cantera.org/license.txt for license and copyright information. @@ -6,28 +6,97 @@ from pathlib import Path import logging import re -from typing import List +from typing import Iterable +from typing_extensions import Self -from ._dataclasses import HeaderFile, Func +from ._dataclasses import HeaderFile, Func, Recipe +from ._helpers import read_config -_logger = logging.getLogger() +_LOGGER = logging.getLogger() -_clib_path = Path(__file__).parent.joinpath("../../../include/cantera/clib").resolve() -_clib_ignore = ["clib_defs.h", "ctmatlab.h"] +_CLIB_PATH = Path(__file__).parents[3] / "include" / "cantera" / "clib" +_CLIB_IGNORE = ["clib_defs.h", "ctmatlab.h"] + +_DATA_PATH = Path(__file__).parent / "_data" class HeaderFileParser: + """ + Parser for header files or corresponding YAML specifications. + + Provides for convenience methods to generate lists of `HeaderFile` objects, which + themselves are used for subsequent code scaffolding. + """ - def __init__(self, path: Path, ignore_funcs: List[str] = None): + def __init__(self, path: Path, ignore_funcs: Iterable[str] = None) -> None: self._path = path self._ignore_funcs = ignore_funcs @classmethod - def from_headers(cls, ignore_files, ignore_funcs) -> List[HeaderFile]: + def headers_from_yaml( + cls: Self, ignore_files: Iterable[str], ignore_funcs: Iterable[str] + ) -> list[HeaderFile]: + """Parse header file YAML configuration.""" + files = sorted( + ff for ff in _DATA_PATH.glob("*.yaml") if ff.name not in ignore_files) + return [cls(ff, ignore_funcs.get(ff.name, []))._parse_yaml() for ff in files] + + def _parse_yaml(self) -> HeaderFile: + def read_docstring(): + doc = [] + with self._path.open("r", encoding="utf-8") as fid: + while True: + line = fid.readline() + if line.startswith("#"): + doc.append(line.removeprefix("#").strip()) + else: + break + if doc and doc[0].startswith("This file is part of "): + return [] + return doc + + msg = f" parsing {self._path.name!r}" + _LOGGER.info(msg) + config = read_config(self._path) + if self._ignore_funcs: + msg = f" ignoring {self._ignore_funcs!r}" + _LOGGER.info(msg) + + recipes = [] + prefix = config["prefix"] + base = config["base"] + parents = config.get("parents", []) + derived = config.get("derived", []) + for recipe in config["recipes"]: + if recipe["name"] in self._ignore_funcs: + continue + uses = recipe.get("uses", []) + if not isinstance(uses, list): + uses = [uses] + recipes.append( + Recipe(recipe["name"], + recipe.get("implements", ""), + uses, + recipe.get("what", ""), + recipe.get("brief", ""), + recipe.get("code", ""), + prefix, + base, + parents, + derived)) + + return HeaderFile(self._path, [], prefix, base, parents, derived, recipes, + read_docstring()) + + @classmethod + def headers_from_h( + cls: Self, ignore_files: Iterable[str], ignore_funcs: Iterable[str] + ) -> list[HeaderFile]: """Parse existing header file.""" - files = [_ for _ in _clib_path.glob("*.h") - if _.name not in ignore_files + _clib_ignore] - return [cls(_, ignore_funcs.get(_.name, []))._parse_h() for _ in files] + files = [ff for ff in _CLIB_PATH.glob("*.h") + if ff.name not in ignore_files + _CLIB_IGNORE] + files.sort() + return [cls(ff, ignore_funcs.get(ff.name, []))._parse_h() for ff in files] def _parse_h(self) -> HeaderFile: ct = self._path.read_text() @@ -41,9 +110,11 @@ def _parse_h(self) -> HeaderFile: parsed = map(Func.from_str, c_functions) - _logger.info(f" parsing {self._path.name!r}") + msg = f" parsing {self._path.name!r}" + _LOGGER.info(msg) if self._ignore_funcs: - _logger.info(f" ignoring {self._ignore_funcs!r}") + msg = f" ignoring {self._ignore_funcs!r}" + _LOGGER.info(msg) parsed = [f for f in parsed if f.name not in self._ignore_funcs] diff --git a/interfaces/sourcegen/sourcegen/_SourceGenerator.py b/interfaces/sourcegen/sourcegen/_SourceGenerator.py index 9849672448..cf79f12bcf 100644 --- a/interfaces/sourcegen/sourcegen/_SourceGenerator.py +++ b/interfaces/sourcegen/sourcegen/_SourceGenerator.py @@ -5,7 +5,6 @@ from abc import ABCMeta, abstractmethod from pathlib import Path -from typing import List from ._dataclasses import HeaderFile @@ -14,9 +13,9 @@ class SourceGenerator(metaclass=ABCMeta): """Specifies the interface of a language-specific SourceGenerator""" @abstractmethod - def __init__(self, out_dir: Path, config: dict): + def __init__(self, out_dir: Path, config: dict, templates: dict) -> None: pass @abstractmethod - def generate_source(self, headers_files: List[HeaderFile]): + def generate_source(self, headers_files: list[HeaderFile]) -> None: pass diff --git a/interfaces/sourcegen/sourcegen/_TagFileParser.py b/interfaces/sourcegen/sourcegen/_TagFileParser.py new file mode 100644 index 0000000000..1a0fb32c8a --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_TagFileParser.py @@ -0,0 +1,299 @@ +"""Parser for tag files and XML generated by doxygen.""" + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +import sys +from pathlib import Path +import re +from typing import Sequence, Iterable +from typing_extensions import Self +import logging +from dataclasses import dataclass +import xml.etree.ElementTree as ET + +from ._dataclasses import ArgList, Param, CFunc +from ._helpers import with_unpack_iter + + +_LOGGER = logging.getLogger(__name__) + +_TAG_PATH = Path(__file__).parents[3] / "build" / "doc" +_XML_PATH = _TAG_PATH / "doxygen" / "xml" + + +@dataclass(frozen=True) +@with_unpack_iter +class TagInfo: + """Represents information parsed from a doxygen tag file.""" + + base: str = "" #: Qualified scope (skipping Cantera namespace) + type: str = "" #: Return type + name: str = "" #: Function name + arglist: str = "" #: Function argument list (original XML string) + anchorfile: str = "" #: doxygen anchor file + anchor: str = "" #: doxygen anchor + + @classmethod + def from_xml(cls: Self, qualified_name: str, xml: str) -> Self: + """Create tag information based on XML data.""" + base = "" + if "::" in qualified_name: + base = qualified_name.split("::", 1)[0] + + xml_tree = ET.fromstring(xml) + return cls(base, + xml_tree.find("type").text, + xml_tree.find("name").text, + xml_tree.find("arglist").text, + xml_tree.find("anchorfile").text.replace(".html", ".xml"), + xml_tree.find("anchor").text) + + def __bool__(self) -> bool: + return all([self.type, self.name, self.arglist, self.anchorfile, self.anchor]) + + @property + def signature(self) -> str: + """Generate function signature based on tag information.""" + return f"{self.type} {self.name}{self.arglist}" + + @property + def id(self) -> str: + """Generate doxygen id.""" + return f"{self.anchorfile.replace('.xml', '')}_1{self.anchor}" + + @property + def qualified_name(self) -> str: + """Return qualified name.""" + if self.base: + return f"{self.base}::{self.name}" + return self.name + + +@dataclass(frozen=True) +@with_unpack_iter +class TagDetails(TagInfo): + """Create tag information based on XML data.""" + + location: str = "" #: File containing doxygen description + briefdescription: str = "" #: Brief doxygen description + parameterlist: list[Param] | None = None #: Annotated doxygen parameter list + + +class TagFileParser: + """Class handling contents of doxygen tag file.""" + + _known: dict[str, str] #: Dictionary of known functions and corresponding XML tags + + def __init__(self, bases: dict[str, str]) -> None: + tag_file = _TAG_PATH / "Cantera.tag" + if not tag_file.exists(): + msg = (f"Tag file does not exist at expected location:\n {tag_file}\n" + "Run 'scons doxygen' to generate.") + _LOGGER.critical(msg) + sys.exit(1) + + logging.info("Parsing doxygen tags...") + doxygen_tags = tag_file.read_text() + self._parse_doxyfile(doxygen_tags, bases) + + def _parse_doxyfile(self, doxygen_tags: str, bases: Sequence[str]) -> None: + """Retrieve class and function information from Cantera namespace.""" + + def xml_compounds(kind: str, names: Sequence[str]) -> dict[str, str]: + regex = re.compile(rf'') + found = [] + compounds = {} + for compound in re.findall(regex, doxygen_tags): + qualified_name = ET.fromstring(compound).find("name").text + compound_name = qualified_name.split(":")[-1] + if compound_name in names: + found.append(compound_name) + compounds[compound_name] = compound + if not (set(names) - set(found)): + return compounds + missing = '", "'.join(set(names) - set(found)) + msg = f"Missing {kind!r} compound(s):\n {missing!r}\nusing regex " + msg += f"{regex}. Continuing with remaining compounds: \n {found!r}" + _LOGGER.error(msg) + + # Parse content of namespace Cantera + namespace = xml_compounds("namespace", ["Cantera"])["Cantera"] + qualified_names = [] + xml_tree = ET.fromstring(namespace).findall("class") + for element in xml_tree: + if element.attrib.get("kind", "") == "class": + qualified_names.append(element.text) + class_names = [_.split(":")[-1] for _ in qualified_names] + + # Handle exceptions for unknown/undocumented classes + unknown = set(bases) - set(class_names) + if "', '".join(unknown): + unknown = "', '".join(unknown) + msg = ("Class(es) in configuration file are missing " + f"from tag file: {unknown!r}") + _LOGGER.critical(msg) + exit(1) + + # Parse content of classes that are specified by the configuration file + class_names = set(bases) & set(class_names) + classes = xml_compounds("class", class_names) + + def xml_members(kind: str, text: str, prefix: str = "") -> dict[str, str]: + regex = re.compile(rf'') + functions = {} + for func in re.findall(regex, text): + func_name = f'{prefix}{ET.fromstring(func).find("name").text}' + if func_name in functions: + # tag file may contain duplicates + if func not in functions[func_name]: + functions[func_name].append(func) + else: + functions[func_name] = [func] + return functions + + # Get known functions from namespace and methods from classes + self._known = xml_members("function", namespace) + for name, cls in classes.items(): + prefix = f"{name}::" + self._known.update(xml_members("function", cls, prefix)) + + def exists(self, cxx_func: str) -> bool: + """Check whether doxygen tag exists.""" + return cxx_func in self._known + + def detect(self, name: str, bases: Iterable[str], permissive: bool = True) -> str: + """Detect qualified method name.""" + for base in bases: + name_ = f"{base}::{name}" + if self.exists(name_): + return name_ + if self.exists(name): + return name + if permissive: + return "" + msg = f"Unable to detect {name!r} in doxygen tags." + _LOGGER.critical(msg) + exit(1) + + def tag_info(self, func_string: str) -> TagInfo: + """Look up tag information based on (partial) function signature.""" + cxx_func = func_string.split("(")[0].split(" ")[-1] + if cxx_func not in self._known: + msg = f"Could not find {cxx_func!r} in doxygen tag file." + _LOGGER.critical(msg) + sys.exit(1) + ix = 0 + if len(self._known[cxx_func]) > 1: + # Disambiguate functions with same name + # TODO: current approach does not use information on default arguments + known_args = [ET.fromstring(xml).find("arglist").text + for xml in self._known[cxx_func]] + known_args = [ArgList.from_xml(al).short_str() for al in known_args] + args = re.findall(re.compile(r"(?<=\().*(?=\))"), func_string) + if not args and "()" in known_args: + # Candidate function without arguments exists + ix = known_args.index("()") + elif not args: + # Function does not use arguments + known = "\n - ".join([""] + known_args) + msg = (f"Need argument list to disambiguate {func_string!r}. " + f"possible matches are:{known}") + _LOGGER.critical(msg) + sys.exit(1) + else: + args = f"({args[0]}" + ix = -1 + for i, known in enumerate(known_args): + if known.startswith(args): + # Detected argument list that uses default arguments + ix = i + break + if ix < 0: + msg = f"Unable to match {func_string!r} to known functions." + _LOGGER.critical(msg) + sys.exit(1) + + return TagInfo.from_xml(cxx_func, self._known[cxx_func][ix]) + + def cxx_func(self, func_string: str) -> CFunc: + """Generate annotated C++ function specification.""" + details = tag_lookup(self.tag_info(func_string)) + ret_param = Param.from_xml(details.type) + + # Merge attributes from doxygen signature and doxygen annotations + args = ArgList.from_xml(details.arglist).params # from signature + args_annotated = details.parameterlist # from documentation + args_merged = [] + for arg in args: + for desc in args_annotated: + if arg.name == desc.name: + args_merged.append( + Param(arg.p_type, arg.name, + desc.description, desc.direction, arg.default)) + break + else: + args_merged.append(Param(arg.p_type, arg.name, "Undocumented.")) + + return CFunc(ret_param.p_type, details.name, ArgList(args_merged), + details.briefdescription, None, ret_param.description, + details.base, []) + + +def tag_lookup(tag_info: TagInfo) -> TagDetails: + """Retrieve tag details from doxygen tree.""" + xml_file = _XML_PATH / tag_info.anchorfile + if not xml_file.exists(): + msg = f"Tag file does not exist at expected location:\n {xml_file}" + _LOGGER.error(msg) + return TagDetails() + + xml_details = xml_file.read_text() + id_ = tag_info.id + regex = re.compile(rf'') + matches = re.findall(regex, xml_details) + + if not matches: + msg = f"No XML matches found for {tag_info.qualified_name!r}" + _LOGGER.error(msg) + return TagDetails() + if len(matches) != 1: + msg = f"Inconclusive XML matches found for {tag_info.qualified_name!r}" + _LOGGER.warning(msg) + matches = matches[:1] + + def no_refs(entry: str) -> str: + # Remove stray XML markup that causes problems with xml.etree + if "") + for ref in re.findall(regex, entry): + entry = entry.replace(ref, "") + entry = entry.replace("", "").replace("", "") + return entry + + def xml_parameterlist(xml_tree: ET) -> list[Param]: + # Resolve/flatten parameter list + names = [] + directions = [] + for element in xml_tree.find("parameternamelist"): + names.append(element.text) + directions.append(element.attrib.get("direction", "")) + description = xml_tree.find("parameterdescription").find("para").text.strip() + return [Param("", n, description, d) for n, d in zip(names, directions)] + + xml = matches[0] + xml_tree = ET.fromstring(no_refs(xml)) + + par_list = [] + xml_details = xml_tree.find("detaileddescription") + if xml_details: + # TODO: confirm that this is always the last "para" entry + xml_list = xml_details.findall("para")[-1].find("parameterlist") + if xml_list: + for item in xml_list.findall("parameteritem"): + par_list.extend(xml_parameterlist(item)) + + return TagDetails(*tag_info, + xml_tree.find("location").attrib["file"], + xml_tree.find("briefdescription").find("para").text.strip(), + par_list) diff --git a/interfaces/sourcegen/sourcegen/_data/README.md b/interfaces/sourcegen/sourcegen/_data/README.md new file mode 100644 index 0000000000..895709da13 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/README.md @@ -0,0 +1,58 @@ +# Automatic Code Generation (Experimental) + +This folder holds YAML configuration data for code generation. Code generation uses +output of the *doxygen* utility for information on function declarations as well as +documentation. The collected information represents annotated CLib functions that form +the basis for specific APIs generated by *sourcegen*. + +## YAML Specification Files + +Each file contains information for CLib headers that implement functions and methods +built around a specific Cantera class. The following fields are supported: + +- `prefix`: Prefix used for CLib functions. +- `base`: Base class of the CLib library; examples: `Solution`, `ThermoPhase`. + Stand-alone functions defined in the `Cantera` namespace use `""` as a base. +- `parents`: List of class parents; used to locate C++ methods (default: `[]`). +- `derived`: List of derived classes; used to locate C++ methods (default: `[]`). +- `recipes`: List of CLib *recipes* (see below). + +In addition, the description of the YAML file (commented block at top of file) may be +used as part of the docstring of auto-generated API files. + +The *sourcegen* utility implements logic to automatically detect CLib functions types +based on a recipe, which is subsequently used to scaffold API functions using dedicated +*Jinja* templates. The following recipe/CLib function types are differentiated: + +- `function`: Regular function defined in the `Cantera` namespace. +- `constructor`: A CLib constructor adds new (or existing) C++ objects to CLib storage. + As all objects are handled via smart `shared_ptr<>`, a CLib constructor requires a + C++ utility functions that returning a pointer to a new object or an object that is + not yet added to CLib storage. Constructor names should start with `new`. +- `destructor`: A CLib destructor removes a C++ object from CLib. Destructor names + should start with `del`. +- `getter`: Implements a getter method of a C++ class. +- `setter`: Implements a setter method of a C++ class. +- `method`: Generic method of a C++ class. +- `noop`: No operation. +- `reserved`: Reserved (hard-coded) CLib functions which include service functions for + CLib storage (examples: `cabinetSize`, `parentHandle`) or functions that do not have + a C++ equivalent (example: `getCanteraError`). + +## YAML Recipes + +Recipes include all information required for the auto-generation of a corresponding +CLib function. Each recipe uses the following fields: + +- `name`: Name of the CLib function to be generated (without prefix). +- `implements`: Optional name or signature of the implemented C++ function/method. If + left empty, *sourcegen* searches for doxygen tags matching the `name` field. + A qualified name is sufficient if C++ functions/methods are unique, for example + `Func1::type`. A full signature is required whenever shortened signatures with + default arguments are used and/or multiple C++ function/method variants exist, for + example `Phase::moleFraction(size_t)`. +- `uses`: Optional list of auxiliary C++ class methods used by the CLib function. The + exact usage depends on the type of the implemented CLib function. +- `what`: Optional override for auto-detected recipe/CLib function type. +- `brief`: Optional override for brief description from doxygen documentation. +- `code`: Optional custom code to override auto-generated code. diff --git a/interfaces/sourcegen/sourcegen/_data/ct_auto.yaml b/interfaces/sourcegen/sourcegen/_data/ct_auto.yaml new file mode 100644 index 0000000000..b9937bc8c3 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/ct_auto.yaml @@ -0,0 +1,28 @@ +# The main library of the auto-generated CLib API contains %Cantera service functions. +# Partially implements a replacement for CLib's traditional @c ct library. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +prefix: ct3 +base: "" +recipes: +- name: getCanteraVersion + implements: version +- name: getGitCommit + implements: gitCommit +- name: getCanteraError +- name: addCanteraDirectory + implements: addDirectory +- name: getDataDirectories +- name: findInputFile +- name: suppress_deprecation_warnings +- name: make_deprecation_warnings_fatal +- name: suppress_warnings +- name: warnings_suppressed +- name: make_warnings_fatal +- name: suppress_thermo_warnings +- name: clearStorage +- name: resetStorage +# - name: setLogWriter # only used by .NET API +# - name: setLogCallback # only used by .NET API diff --git a/interfaces/sourcegen/sourcegen/_data/ctfunc_auto.yaml b/interfaces/sourcegen/sourcegen/_data/ctfunc_auto.yaml new file mode 100644 index 0000000000..ad82a38524 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/ctfunc_auto.yaml @@ -0,0 +1,36 @@ +# Auto-generated CLib API for %Cantera's Func1 class. +# Implements a replacement for CLib's traditional @c ctfunc library. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +prefix: func13 +base: Func1 +recipes: +- name: check + implements: checkFunc1 +- name: newBasic + implements: newFunc1(const string&, double) +- name: newAdvanced + implements: newFunc1(const string&, const vector&) +- name: newCompound + implements: newFunc1(const string&, const shared_ptr, const shared_ptr) +- name: newModified + implements: newFunc1(const string&, const shared_ptr, double) +- name: newSum + implements: newSumFunction +- name: newDiff + implements: newDiffFunction +- name: newProd + implements: newProdFunction +- name: newRatio + implements: newRatioFunction +- name: type +- name: eval +- name: newDerivative + what: constructor + implements: Func1::derivative +- name: write +- name: del + what: destructor +- name: cabinetSize diff --git a/interfaces/sourcegen/sourcegen/_data/ctkin_auto.yaml b/interfaces/sourcegen/sourcegen/_data/ctkin_auto.yaml new file mode 100644 index 0000000000..2a1c6e2ad9 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/ctkin_auto.yaml @@ -0,0 +1,19 @@ +# Auto-generated CLib API for %Cantera's Kinetics class. +# Partially implements a replacement for CLib's traditional @c ct library. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +prefix: kin3 +base: Kinetics +parents: [] # List of parent classes +derived: [] # List of specializations +recipes: +- name: nReactions +- name: kineticsType +- name: getFwdRatesOfProgress +- name: del + what: noop + brief: Destructor; required by some APIs although object is managed by Solution. +- name: cabinetSize +- name: parentHandle diff --git a/interfaces/sourcegen/sourcegen/_data/ctsol_auto.yaml b/interfaces/sourcegen/sourcegen/_data/ctsol_auto.yaml new file mode 100644 index 0000000000..4f0b8b6b54 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/ctsol_auto.yaml @@ -0,0 +1,43 @@ +# Auto-generated CLib API for %Cantera's Solution class. +# Partially implements a replacement for CLib's traditional @c ct library. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +prefix: sol3 +base: Solution +parents: [] # List of parent classes +derived: [Interface] # List of specializations +recipes: +- name: newSolution + implements: newSolution(const string&, const string&, const string&) + uses: [thermo, kinetics, transport] +- name: newInterface + implements: + newInterface(const string&, const string&, const vector>&) + uses: [thermo, kinetics] +- name: del + uses: [thermo, kinetics, transport] +- name: name +- name: setName +- name: thermo +- name: kinetics +- name: transport +- name: setTransportModel + code: |- + try { + auto obj = SolutionCabinet::at(handle); + TransportCabinet::del( + TransportCabinet::index(*(obj->transport()), handle)); + obj->setTransportModel(model); + return TransportCabinet::add(obj->transport(), handle); + } catch (...) { + return handleAllExceptions(-2, ERR); + } +- name: nAdjacent +- name: adjacent + implements: Solution::adjacent(size_t) + uses: [thermo, kinetics, transport] + what: constructor # registers object in CLib storage +# - name: adjacentName +- name: cabinetSize diff --git a/interfaces/sourcegen/sourcegen/_data/ctthermo_auto.yaml b/interfaces/sourcegen/sourcegen/_data/ctthermo_auto.yaml new file mode 100644 index 0000000000..a5cb8fafd9 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/ctthermo_auto.yaml @@ -0,0 +1,59 @@ +# Auto-generated CLib API for %Cantera's ThermoPhase class. +# Partially implements a replacement for CLib's traditional @c ct library. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +prefix: thermo3 +base: ThermoPhase +parents: [Phase] # List of parent classes +derived: [] # List of specializations +recipes: +- name: report +- name: nElements +- name: nSpecies +- name: temperature +- name: setTemperature +- name: pressure +- name: setPressure +- name: density +- name: setDensity +- name: molarDensity +- name: meanMolecularWeight +- name: moleFraction + implements: Phase::moleFraction(size_t) +- name: massFraction + implements: Phase::massFraction(size_t) +- name: getMoleFractions + uses: nSpecies +- name: getMassFractions + uses: nSpecies +- name: setMoleFractions + uses: nSpecies +- name: setMassFractions + uses: nSpecies +- name: setMoleFractionsByName + implements: Phase::setMoleFractionsByName(const string&) +- name: setMassFractionsByName + implements: Phase::setMassFractionsByName(const string&) +- name: enthalpy_mole +- name: enthalpy_mass +- name: entropy_mole +- name: entropy_mass +- name: intEnergy_mole +- name: intEnergy_mass +- name: cp_mole +- name: cp_mass +- name: getPartialMolarEnthalpies +- name: getPartialMolarEntropies +- name: getPartialMolarIntEnergies +- name: getPartialMolarCp +- name: getPartialMolarVolumes +- name: equilibrate + implements: + ThermoPhase::equilibrate(const string&, const string&, double, int, int, int) +- name: del + what: noop + brief: Destructor; required by some APIs although object is managed by Solution. +- name: cabinetSize +- name: parentHandle diff --git a/interfaces/sourcegen/sourcegen/_data/cttrans_auto.yaml b/interfaces/sourcegen/sourcegen/_data/cttrans_auto.yaml new file mode 100644 index 0000000000..671e68ca12 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/_data/cttrans_auto.yaml @@ -0,0 +1,20 @@ +# Auto-generated CLib API for %Cantera's Transport class. +# Partially implements a replacement for CLib's traditional @c ct library. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +prefix: trans3 +base: Transport +parents: [] # List of parent classes +derived: [] # List of specializations +recipes: +- name: transportModel +- name: viscosity +- name: thermalConductivity +- name: getMixDiffCoeffs +- name: del + what: noop + brief: Destructor; required by some APIs although object is managed by Solution. +- name: cabinetSize +- name: parentHandle diff --git a/interfaces/sourcegen/sourcegen/_dataclasses.py b/interfaces/sourcegen/sourcegen/_dataclasses.py index f8f45d644e..7d02d9ab8b 100644 --- a/interfaces/sourcegen/sourcegen/_dataclasses.py +++ b/interfaces/sourcegen/sourcegen/_dataclasses.py @@ -1,10 +1,13 @@ +"""Data classes common to sourcegen scaffolders.""" + # This file is part of Cantera. See License.txt in the top-level directory or # at https://cantera.org/license.txt for license and copyright information. from dataclasses import dataclass import re from pathlib import Path -from typing import List, Any, Tuple, Iterator +from typing import Any, Iterator +from typing_extensions import Self from ._helpers import with_unpack_iter @@ -12,19 +15,49 @@ @dataclass(frozen=True) @with_unpack_iter class Param: - """Represents a function parameter""" + """Class representing a function parameter.""" + + p_type: str #: Parameter type + name: str = "" #: Parameter name; may be empty if used for return argument - p_type: str - name: str = "" + description: str = "" #: Parameter description (optional annotation) + direction: str = "" #: Direction of parameter (optional annotation) + default: Any = None #: Default value (optional) @classmethod - def from_str(cls, param: str) -> 'Param': - """Generate Param from string parameter""" + def from_str(cls: Self, param: str, doc: str = "") -> Self: + """Generate Param from parameter string.""" + param = param.strip() + default = None + if "=" in param: + param, _, default = param.partition("=") parts = param.strip().rsplit(" ", 1) if len(parts) == 2 and parts[0] not in ["const", "virtual", "static"]: - return cls(*parts) + if "@param" not in doc: + return cls(*parts, "", "", default) + items = doc.split() + if items[1] != parts[1]: + msg = f"Documented variable {items[1]!r} does not match {parts[1]!r}" + raise ValueError(msg) + direction = items[0].split("[")[1].split("]")[0] if "[" in items[0] else "" + return cls(*parts, " ".join(items[2:]), direction, default) return cls(param) + @classmethod + def from_xml(cls: Self, param: str) -> Self: + """ + Generate Param from XML string. + + Note: Converts from doxygen style to simplified C++ whitespace notation. + """ + for rep in [(" &", "& "), ("< ", "<"), (" >", ">"), (" *", "* ")]: + param = param.replace(*rep) + return cls.from_str(param.strip()) + + def short_str(self) -> str: + """String representation of the parameter without parameter name.""" + return self.p_type + def long_str(self) -> str: """String representation of the parameter with parameter name.""" if not self.name: @@ -34,58 +67,74 @@ def long_str(self) -> str: @dataclass(frozen=True) class ArgList: - """Represents a function argument list""" + """Represents a function argument list.""" - params: List[Param] - spec: str = "" #: trailing Specification (example: `const`) + params: list[Param] #: List of function parameters + spec: str = "" #: Trailing specification (example: `const`) @staticmethod - def _split_arglist(arglist: str) -> Tuple[str, str]: + def _split_arglist(arglist: str) -> tuple[str, str]: """Split string into text within parentheses and trailing specification.""" arglist = arglist.strip() if not arglist: return "", "" spec = arglist[arglist.rfind(")") + 1:] # match text within parentheses - regex = re.compile(r'(?<=\().*(?=\))', flags=re.DOTALL) + regex = re.compile(r"(?<=\().*(?=\))", flags=re.DOTALL) arglist = re.findall(regex, arglist)[0] return arglist, spec @classmethod - def from_str(cls, arglist: str) -> 'ArgList': + def from_str(cls: Self, arglist: str) -> Self: """Generate ArgList from string argument list.""" arglist, spec = cls._split_arglist(arglist) if not arglist: return cls([], spec) return cls([Param.from_str(arg) for arg in arglist.split(",")], spec) - def __getitem__(self, k): + @classmethod + def from_xml(cls: Self, arglist: str) -> Self: + """Generate ArgList from XML string argument list.""" + arglist, spec = cls._split_arglist(arglist) + if not arglist: + return cls([], spec) + return cls([Param.from_xml(arg) for arg in arglist.split(",")], spec) + + def __len__(self) -> int: + return len(self.params) + + def __getitem__(self, k: int) -> Param: return self.params[k] - def __iter__(self) -> "Iterator[Param]": + def __iter__(self) -> Iterator[Param]: return iter(self.params) + def short_str(self) -> str: + """String representation of the argument list without parameter names.""" + args = ", ".join(par.short_str() for par in self.params) + return f"({args}) {self.spec}".strip() + def long_str(self) -> str: """String representation of the argument list with parameter names.""" - args = ', '.join([par.long_str() for par in self.params]) + args = ", ".join([par.long_str() for par in self.params]) return f"({args}) {self.spec}".strip() @dataclass(frozen=True) @with_unpack_iter class Func: - """Represents a function parsed from a C header file.""" + """Represents a function declaration in a C/C++ header file.""" - ret_type: str # may include leading specifier - name: str - arglist: ArgList + ret_type: str #: Return type; may include leading specifier + name: str #: Function name + arglist: ArgList #: Argument list @classmethod - def from_str(cls, func: str) -> 'Func': + def from_str(cls: Self, func: str) -> Self: """Generate Func from declaration string of a function.""" - func = func.strip() - # match all characters before an opening parenthesis '(' or end of line - name = re.findall(r'.*?(?=\(|$)', func)[0] + func = func.rstrip(";").strip() + # match all characters before an opening parenthesis "(" or end of line + name = re.findall(r".*?(?=\(|$)", func)[0] arglist = ArgList.from_str(func.replace(name, "").strip()) r_type = "" if " " in name: @@ -99,8 +148,87 @@ def declaration(self) -> str: @dataclass(frozen=True) @with_unpack_iter -class HeaderFile: - """Represents information about a parsed C header file""" +class CFunc(Func): + """Represents an annotated function declaration in a C/C++ header file.""" + + brief: str = "" #: Brief description (optional) + implements: Self = None #: Implemented C++ function/method (optional) + returns: str = "" #: Description of returned value (optional) + base: str = "" #: Qualified scope of function/method (optional) + uses: list[Self] | None = None #: List of auxiliary C++ methods (optional) + + @classmethod + def from_str(cls: Self, func: str, brief: str = "") -> Self: + """Generate annotated CFunc from header block of a function.""" + lines = func.split("\n") + func = Func.from_str(lines[-1]) + if len(lines) == 1: + return cls(*func, brief, None, "", "", []) + returns = "" + args = [] + for ix, line in enumerate(lines[:-1]): + line = line.strip().lstrip("*").strip() + if ix == 1 and not brief: + brief = line + elif line.startswith("@param"): + # assume that variables are documented in order + arg = func.arglist[len(args)].long_str() + args.append(Param.from_str(arg, line)) + elif line.startswith("@returns"): + returns = line.lstrip("@returns").strip() + args = ArgList(args) + return cls(func.ret_type, func.name, args, brief, None, returns, "", []) + + def short_declaration(self) -> str: + """Return a short string representation.""" + ret = (f"{self.name}{self.arglist.short_str()}").strip() + if self.base: + return f"{self.ret_type} {self.base}::{ret}" + return f"{self.ret_type} {ret}" + + @property + def ret_param(self) -> Param: + """Assemble return parameter.""" + return Param(self.ret_type, "", self.returns) + + +@dataclass +@with_unpack_iter +class Recipe: + """ + Represents a recipe for a CLib method. + + Class holds contents of YAML header configuration. + """ + + name: str #: name of method (without prefix) + implements: str #: signature of implemented C++ function/method + uses: str | list[str] #: auxiliary C++ methods used by recipe + what: str #: override auto-detection of recipe type + brief: str #: override brief description from doxygen documentation + code: str #: custom code to override autogenerated code (stub: to be implemented) - path: Path - funcs: List[Func] + prefix: str #: prefix used for CLib access function + base: str #: C++ class implementing method (if applicable) + parents: list[str] #: list of C++ parent classes (if applicable) + derived: list[str] #: list of C++ specializations (if applicable) + + +@dataclass +class HeaderFile: + """Represents information about a parsed C header file.""" + + path: Path #: output folder + funcs: list[Func] #: list of functions to be scaffolded + + prefix: str = "" #: prefix used for CLib function names + base: str = "" #: base class of C++ methods (if applicable) + parents: list[str] | None = None #: list of C++ parent class(es) + derived: list[str] | None = None #: list of C++ specialization(s) + recipes: list[Recipe] | None = None #: list of header recipes read from YAML + docstring: list[str] | None = None #: lines representing docstring of YAML file + + def output_name(self, auto: str = "3", suffix: str = "") -> Path: + """Return updated path.""" + ret = self.path.parent / self.path.name.replace("_auto", auto) + return ret.with_suffix(suffix) diff --git a/interfaces/sourcegen/sourcegen/_helpers.py b/interfaces/sourcegen/sourcegen/_helpers.py index 901dc1cf10..7049eba8ad 100644 --- a/interfaces/sourcegen/sourcegen/_helpers.py +++ b/interfaces/sourcegen/sourcegen/_helpers.py @@ -5,6 +5,7 @@ from pathlib import Path from ruamel import yaml + def read_config(config_file: Path) -> dict: """Read YAML configuration file.""" if config_file.is_file(): diff --git a/interfaces/sourcegen/sourcegen/_orchestrate.py b/interfaces/sourcegen/sourcegen/_orchestrate.py index dc0831ab89..4919eab037 100644 --- a/interfaces/sourcegen/sourcegen/_orchestrate.py +++ b/interfaces/sourcegen/sourcegen/_orchestrate.py @@ -6,14 +6,14 @@ from pathlib import Path import logging import sys -from typing import List, Dict from ._HeaderFileParser import HeaderFileParser from ._SourceGenerator import SourceGenerator +from .clib import CLibSourceGenerator from ._helpers import read_config -_logger = logging.getLogger() +_LOGGER = logging.getLogger() class CustomFormatter(logging.Formatter): """Minimalistic logging output""" @@ -23,28 +23,51 @@ def format(self, record): return formatter.format(record) -def generate_source(lang: str, out_dir: str=""): +def generate_source(lang: str, out_dir: str = "", verbose = False) -> None: """Main entry point of sourcegen.""" loghandler = logging.StreamHandler(sys.stdout) loghandler.setFormatter(CustomFormatter()) - _logger.handlers.clear() - _logger.addHandler(loghandler) - _logger.setLevel(logging.DEBUG) - _logger.info(f"Generating {lang!r} source files...") + _LOGGER.handlers.clear() + _LOGGER.addHandler(loghandler) + _LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO) + + if not out_dir: + _LOGGER.critical("Aborting: sourcegen requires output folder information.") + exit(1) module = importlib.import_module(__package__ + "." + lang) root = Path(module.__file__).parent config = read_config(root / "config.yaml") templates = read_config(root / "templates.yaml") - ignore_files: List[str] = config.pop("ignore_files", []) - ignore_funcs: Dict[str, List[str]] = config.pop("ignore_funcs", {}) + ignore_files: list[str] = config.pop("ignore_files", []) + ignore_funcs: dict[str, list[str]] = config.pop("ignore_funcs", {}) + + msg = f"Starting sourcegen for {lang!r} API" + _LOGGER.info(msg) - files = HeaderFileParser.from_headers(ignore_files, ignore_funcs) + if lang == "clib": + # prepare for generation of CLib headers in main processing step + files = HeaderFileParser.headers_from_yaml(ignore_files, ignore_funcs) + elif lang == "csharp": + # csharp parses existing (traditional) CLib header files + files = HeaderFileParser.headers_from_h(ignore_files, ignore_funcs) + else: + # generate CLib headers from YAML specifications as a preprocessing step + files = HeaderFileParser.headers_from_yaml(ignore_files, ignore_funcs) + clib_root = Path(__file__).parent / "clib" + clib_config = read_config(clib_root / "config.yaml") + clib_templates = read_config(clib_root / "templates.yaml") + for key in ["ignore_files", "ignore_funcs"]: + clib_config.pop(key) + clib_scaffolder = CLibSourceGenerator(None, clib_config, clib_templates) + clib_scaffolder.resolve_tags(files) # find and instantiate the language-specific SourceGenerator + msg = f"Generating {lang!r} source files..." + _LOGGER.info(msg) _, scaffolder_type = inspect.getmembers(module, lambda m: inspect.isclass(m) and issubclass(m, SourceGenerator))[0] scaffolder: SourceGenerator = scaffolder_type(out_dir, config, templates) scaffolder.generate_source(files) - _logger.info("Done.") + _LOGGER.info("Done.") diff --git a/interfaces/sourcegen/sourcegen/clib/_CLibSourceGenerator.py b/interfaces/sourcegen/sourcegen/clib/_CLibSourceGenerator.py new file mode 100644 index 0000000000..6990319682 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/clib/_CLibSourceGenerator.py @@ -0,0 +1,563 @@ +"""Generator for CLib source files.""" + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +import sys +from pathlib import Path +import logging + +from jinja2 import Environment, BaseLoader + +from ._Config import Config + +from .._dataclasses import HeaderFile, Param, ArgList, CFunc, Recipe +from .._SourceGenerator import SourceGenerator +from .._TagFileParser import TagFileParser + + +_LOGGER = logging.getLogger() + +class CLibSourceGenerator(SourceGenerator): + """The SourceGenerator for generating CLib.""" + + _clib_bases: list[str] = None #: list of bases provided via YAML configurations + + def __init__(self, out_dir: str, config: dict, templates: dict) -> None: + self._out_dir = out_dir or None + if self._out_dir is not None: + self._out_dir = Path(out_dir) + self._out_dir.mkdir(parents=True, exist_ok=True) + self._config = Config.from_parsed(**config) + self._templates = templates + self._doxygen_tags = None + + @staticmethod + def _javadoc_comment(block: str) -> str: + """Build deblanked JavaDoc-style (C-style) comment block.""" + block = ["/**"] + block.strip().split("\n") + block = "\n * ".join(block).strip() + "\n */" + return "\n".join([line.rstrip() for line in block.split("\n")]) + + def _scaffold_annotation(self, c_func: CFunc, what: str) -> str: + """Build annotation block via Jinja.""" + loader = Environment(loader=BaseLoader, trim_blocks=True, lstrip_blocks=True) + par_template = loader.from_string(self._templates["clib-param"]) + template = loader.from_string(self._templates["clib-comment"]) + + def param(item: Param) -> str: + ret = par_template.render(par=item) + return f"{ret:<19} {item.description}" + + implements = what + if isinstance(c_func.implements, CFunc): + implements += f": {c_func.implements.short_declaration()}" + block = template.render( + brief=c_func.brief, + params=[param(par) for par in c_func.arglist], + returns=c_func.returns, implements=implements, + relates=[f"{uu.base}::{uu.name}()" for uu in c_func.uses]) + return self._javadoc_comment(block) + + def _handle_crosswalk(self, what: str, crosswalk: dict, derived: list[str]) -> str: + """Crosswalk for object handle.""" + cabinet = None + classes = list(self._config.includes.keys()) + derived + for base in classes: + ret_type = what.replace(f"<{base}>", "") + if ret_type in crosswalk: + ret_type = crosswalk[ret_type] + cabinet = base + break + if cabinet: + # successful crosswalk with cabinet object + return cabinet + + msg = f"Failed crosswalk for handle type {what!r} using {classes}." + _LOGGER.critical(msg) + sys.exit(1) + + def _ret_crosswalk( + self, what: str, derived: list[str]) -> tuple[Param, list[Param]]: + """Crosswalk for return type.""" + what = what.replace("virtual ", "") + if what in self._config.ret_type_crosswalk: + ret_type = self._config.ret_type_crosswalk[what] + if ret_type == "char*": + # string expressions require special handling + returns = Param( + "int", "", + "Actual length of string or -1 for exception handling.") + buffer = [ + Param("int", "bufLen", "Length of reserved array.", "in"), + Param(ret_type, "buf", "Returned string value.", "out")] + return returns, buffer + if ret_type == "void": + returns = Param( + "int", "", "Zero for success or -1 for exception handling.") + return returns, [] + if not any([what.startswith("shared_ptr"), ret_type.endswith("[]")]): + # direct correspondence + return Param(ret_type), [] + + # all other types require reserved buffers + what = "vector" if what.startswith("vector") else "array" + returns = Param( + "int", "", f"Actual length of {what} including \0 or -1 for exception handling.") + buffer = [ + Param("int", "bufLen", "Length of reserved array.", "in"), + Param(ret_type, "valueBuf", f"Returned {what} value.", "out")] + return returns, buffer + + if "shared_ptr" in what: + # check for crosswalk with object from includes + handle = self._handle_crosswalk( + what, self._config.ret_type_crosswalk, derived) + returns = Param( + "int", "", + f"Handle to stored {handle} object or -1 for exception handling.") + return returns, [] + + msg = f"Failed crosswalk for return type {what!r}." + _LOGGER.critical(msg) + sys.exit(1) + + def _prop_crosswalk(self, par_list: list[Param]) -> list[Param]: + """Crosswalk for argument type.""" + if not par_list: + return [] + params = [] + for par in par_list: + what = par.p_type + if what in self._config.prop_type_crosswalk: + if "vector<" in what: + params.append( + Param("int", f"{par.name}Len", + f"Length of vector reserved for {par.name}.", "in")) + elif what.endswith("* const") or what.endswith("double*"): + direction = "in" if what.startswith("const") else "out" + params.append( + Param("int", f"{par.name}Len", + f"Length of array reserved for {par.name}.", direction)) + ret_type = self._config.prop_type_crosswalk[what] + params.append(Param(ret_type, par.name, par.description, par.direction)) + elif "shared_ptr" in what: + handle = self._handle_crosswalk( + what, self._config.prop_type_crosswalk, []) + if "vector<" in what: + params.append( + Param("int", f"{par.name}Len", + f"Length of array reserved for {par.name}.", "in")) + description = f"Memory holding {handle} objects. " + description += par.description + params.append(Param("const int*", par.name, description.strip())) + else: + description = f"Integer handle to {handle} object. " + description += par.description + params.append( + Param("int", par.name, description.strip(), par.direction)) + else: + msg = f"Failed crosswalk for argument type {what!r}." + _LOGGER.critical(msg) + sys.exit(1) + return params + + @staticmethod + def _reverse_crosswalk(c_func: CFunc, base: str) -> tuple[dict[str, str], set[str]]: + """Translate CLib arguments back to Jinja argument list.""" + handle = "" + args = [] + lines = [] + buffer = [] + bases = set() + + def shared_object(cxx_type) -> str: + """Extract object type from shared_ptr.""" + if "shared_ptr<" not in cxx_type: + return None + return cxx_type.split("<")[-1].split(">")[0] + + c_args = c_func.arglist + cxx_func = c_func.implements + if not cxx_func: + if len(c_args) and "char*" in c_args[-1].p_type: + cxx_func = CFunc("string", "dummy", ArgList([]), "", None, "", "base") + else: + cxx_func = CFunc("void", "dummy", ArgList([]), "", None, "", "base") + cxx_ix = 0 + check_array = False + for c_ix, c_par in enumerate(c_func.arglist): + c_name = c_par.name + if cxx_ix >= len(cxx_func.arglist): + if c_ix == 0 and cxx_func.base and "len" not in c_name.lower(): + handle = c_name + c_ix += 1 + if c_ix == len(c_args): + break + cxx_type = cxx_func.ret_type + + # Handle output buffer + if "string" in cxx_type: # or cxx_func.name == "dummy": + buffer = ["string out", + f"copyString(out, {c_args[c_ix+1].name}, " + f"{c_args[c_ix].name});", + "int(out.size()) + 1"] # include \0 + else: + msg = (f"Scaffolding failed for {c_func.name!r}: reverse crosswalk " + f"not implemented for {cxx_type!r}:\n{c_func.declaration()}") + _LOGGER.critical(msg) + exit(1) + break + + cxx_arg = cxx_func.arglist[cxx_ix] + if c_name != cxx_arg.name: + # Encountered object handle or length indicator + if c_ix == 0: + handle = c_name + elif c_name.endswith("Len"): + check_array = True + else: + msg = (f"Scaffolding failed for {c_func.name!r}: " + f"unexpected behavior for {c_name!r}.") + _LOGGER.critical(msg) + exit(1) + continue + + cxx_type = cxx_arg.p_type + if check_array: + # Need to handle cross-walked parameter with length information + c_prev = c_args[c_ix-1].name + if "vector> + cxx_type = cxx_type.lstrip("const ").rstrip("&") + lines.extend([ + f"{cxx_type} {c_name}_;", + f"for (int i = 0; i < {c_prev}; i++) {{", + f" {c_name}_.push_back({base}Cabinet::at({c_name}[i]));", + "}", + ]) + args.append(f"{c_name}_") + elif "vector" in cxx_type: + # Example: vector par_(par, par + parLen); + cxx_type = cxx_type.rstrip("&") + lines.append( + f"{cxx_type} {c_name}_({c_name}, {c_name} + {c_prev});") + args.append(f"{c_name}_") + elif "*" in cxx_type: + # Can be passed directly; example: double *const + args.append(c_name) + else: + msg = (f"Scaffolding failed for {c_func.name!r}: reverse " + f"crosswalk not implemented for {cxx_type!r}.") + _LOGGER.critical(msg) + exit(1) + check_array = False + elif "shared_ptr" in cxx_type: + # Retrieve object from cabinet + obj_base = shared_object(cxx_type) + args.append(f"{base}Cabinet::at({c_name})") + if obj_base != base: + bases |= {obj_base} + elif cxx_type == "bool": + lines.append(f"bool {c_name}_ = ({c_name} != 0);") + args.append(f"{c_name}_") + else: + # Regular parameter + args.append(c_name) + cxx_ix += 1 + + # Obtain class and getter for managed objects + uses = [(shared_object(uu.ret_type), uu.name) for uu in c_func.uses] + bases |= {uu[0] for uu in uses if uu[0]} + + # Ensure that all error codes are set correctly + error = [-1, "ERR"] + cxx_type = cxx_func.ret_type + if cxx_type.endswith("int") or cxx_type.endswith("size_t"): + error = ["ERR", "ERR"] + elif cxx_type.endswith("double"): + error = ["DERR", "DERR"] + elif "shared_ptr" in cxx_type: + obj_base = shared_object(cxx_type) + if obj_base == base: + buffer = ["auto obj", "", f"{obj_base}Cabinet::index(*obj)"] + else: + buffer = ["auto obj", "", f"{obj_base}Cabinet::index(*obj, {handle})"] + bases |= {obj_base} + error = ["-2", "ERR"] + elif cxx_type.endswith("void"): + buffer = ["", "", "0"] + + ret = { + "handle": handle, "lines": lines, "buffer": buffer, "uses": uses, + "cxx_base": base, "cxx_name": cxx_func.name, "cxx_args": args, + "cxx_implements": cxx_func.short_declaration(), "error": error, + "c_func": c_func.name, "c_args": [arg.name for arg in c_func.arglist], + } + return ret, bases + + def _scaffold_body(self, c_func: CFunc, recipe: Recipe) -> tuple[str, set[str]]: + """Scaffold body of generic CLib function via Jinja.""" + loader = Environment(loader=BaseLoader, trim_blocks=True, lstrip_blocks=True) + args, bases = self._reverse_crosswalk(c_func, recipe.base) + args["what"] = recipe.what + + if recipe.code: + # override auto-generated code + template = loader.from_string(self._templates["clib-custom-code"]) + args["lines"] = recipe.code.strip(" \n").split("\n") + + elif recipe.what == "noop": + template = loader.from_string(self._templates["clib-noop"]) + + elif recipe.what == "function": + template = loader.from_string(self._templates["clib-function"]) + + elif recipe.what == "constructor": + template = loader.from_string(self._templates["clib-constructor"]) + + elif recipe.what == "destructor": + template = loader.from_string(self._templates["clib-destructor"]) + + elif recipe.what == "method": + template = loader.from_string(self._templates["clib-method"]) + + elif recipe.what == "getter": + if "void" in c_func.implements.ret_type: + template = loader.from_string(self._templates["clib-array-getter"]) + else: + template = loader.from_string(self._templates["clib-method"]) + + elif recipe.what == "setter": + if "*" in c_func.implements.arglist[0].p_type: + template = loader.from_string(self._templates["clib-array-setter"]) + else: + template = loader.from_string(self._templates["clib-method"]) + + elif recipe.what == "reserved": + args["cabinets"] = [kk for kk in self._clib_bases if kk] + template = loader.from_string( + self._templates[f"clib-reserved-{recipe.name}-cpp"]) + + else: + msg = f"{recipe.what!r} not implemented: {c_func.name!r}." + _LOGGER.critical(msg) + exit(1) + + return template.render(**args), bases + + def _resolve_recipe(self, recipe: Recipe) -> CFunc: + """Build CLib header from recipe and doxygen annotations.""" + def merge_params(implements: str, cxx_func: CFunc) -> tuple[list[Param], int]: + """Create preliminary CLib argument list.""" + obj_handle = [] + if "::" in implements: + # If class method, add handle as first parameter + what = implements.split("::")[0] + obj_handle.append( + Param("int", "handle", f"Handle to queried {what} object.")) + if "(" not in implements: + return obj_handle + cxx_func.arglist.params, cxx_func + + # Signature may skip C++ default parameters + args_short = CFunc.from_str(implements).arglist + if len(args_short) < len(cxx_func.arglist): + cxx_arglist = ArgList(cxx_func.arglist[:len(args_short)]) + cxx_func = CFunc(cxx_func.ret_type, cxx_func.name, + cxx_arglist, cxx_func.brief, cxx_func.implements, + cxx_func.returns, cxx_func.base, cxx_func.uses) + + return obj_handle + cxx_func.arglist.params, cxx_func + + func_name = f"{recipe.prefix}_{recipe.name}" + reserved = ["cabinetSize", "parentHandle", + "getCanteraError", "clearStorage", "resetStorage"] + if recipe.name in reserved: + recipe.what = "reserved" + loader = Environment(loader=BaseLoader) + msg = f" generating {func_name!r} -> {recipe.what}" + _LOGGER.debug(msg) + header = loader.from_string( + self._templates[f"clib-reserved-{recipe.name}-h"] + ).render(base=recipe.base, prefix=recipe.prefix) + return CFunc.from_str(header, brief=recipe.brief) + + # Ensure that all functions/methods referenced in recipe are detected correctly + bases = [recipe.base] + recipe.parents + recipe.derived + if not recipe.implements: + recipe.implements = self._doxygen_tags.detect(recipe.name, bases) + recipe.uses = [self._doxygen_tags.detect(uu.split("(")[0], bases, False) + for uu in recipe.uses] + + cxx_func = None + ret_param = Param("void") + args = [] + brief = "" + + if recipe.implements: + msg = f" generating {func_name!r} -> {recipe.implements}" + _LOGGER.debug(msg) + cxx_func = self._doxygen_tags.cxx_func(recipe.implements) + + # Convert C++ return type to format suitable for crosswalk: + # Incompatible return parameters are buffered and appended to back + ret_param, buffer_params = self._ret_crosswalk( + cxx_func.ret_type, recipe.derived) + par_list, cxx_func = merge_params(recipe.implements, cxx_func) + prop_params = self._prop_crosswalk(par_list) + brief = cxx_func.brief + args = prop_params + buffer_params + + if cxx_func and not recipe.what: + # Autodetection of CLib function purpose ("what") + cxx_arglen = len(cxx_func.arglist) + if not cxx_func.base: + if (cxx_func.name.startswith("new") and + any(base in cxx_func.ret_type + for base in [recipe.base] + recipe.derived)): + recipe.what = "constructor" + else: + recipe.what = "function" + elif "void" not in cxx_func.ret_type and cxx_arglen == 0: + recipe.what = "getter" + elif "void" in cxx_func.ret_type and cxx_arglen == 1: + p_type = cxx_func.arglist[0].p_type + if cxx_func.name.startswith("get"): + recipe.what = "getter" + elif "*" in p_type and not p_type.startswith("const"): + recipe.what = "getter" # getter assigns to existing array + else: + recipe.what = "setter" + elif any(recipe.implements.startswith(base) + for base in [recipe.base] + recipe.parents + recipe.derived): + recipe.what = "method" + else: + _LOGGER.critical("Unable to auto-detect function type.") + exit(1) + + elif recipe.name == "del" and not recipe.what: + recipe.what = "destructor" + + if recipe.what in ["destructor", "noop"]: + # these function types don't have direct C++ equivalents + msg = f" generating {func_name!r} -> {recipe.what}" + _LOGGER.debug(msg) + if recipe.what == "noop": + args = [] + brief= "No operation." + ret_param = Param( + "int", "", "Always zero.") + else: + args = [Param("int", "handle", f"Handle to {recipe.base} object.")] + brief= f"Delete {recipe.base} object." + ret_param = Param( + "int", "", "Zero for success and -1 for exception handling.") + buffer_params = [] + + if recipe.brief: + brief = recipe.brief + uses = [self._doxygen_tags.cxx_func(uu) for uu in recipe.uses] + return CFunc(ret_param.p_type, func_name, ArgList(args), brief, cxx_func, + ret_param.description, None, uses) + + def _write_header(self, headers: HeaderFile) -> None: + """Parse header specification and generate header file.""" + loader = Environment(loader=BaseLoader, trim_blocks=True, lstrip_blocks=True) + + filename = headers.output_name(suffix=".h", auto="3") + msg = f" scaffolding {filename.name!r}" + _LOGGER.info(msg) + + template = loader.from_string(self._templates["clib-definition"]) + declarations = [] + for c_func, recipe in zip(headers.funcs, headers.recipes): + msg = f" scaffolding {c_func.name!r} header" + _LOGGER.debug(msg) + declarations.append( + template.render( + declaration=c_func.declaration(), + annotations=self._scaffold_annotation(c_func, recipe.what))) + declarations = "\n\n".join(declarations) + + guard = f"__{filename.name.upper().replace('.', '_')}__" + template = loader.from_string(self._templates["clib-header-file"]) + output = template.render( + name=filename.stem, guard=guard, declarations=declarations, + prefix=headers.prefix, base=headers.base, docstring=headers.docstring) + + out = (Path(self._out_dir) / + "include" / "cantera" / "clib_experimental" / filename.name) + msg = f" writing {filename.name!r}" + _LOGGER.info(msg) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(output + "\n") + + def _write_implementation(self, headers: HeaderFile) -> None: + """Parse header specification and generate implementation file.""" + loader = Environment(loader=BaseLoader, trim_blocks=True, lstrip_blocks=True) + + filename = headers.output_name(suffix=".cpp", auto="3") + msg = f" scaffolding {filename.name!r}" + _LOGGER.info(msg) + + template = loader.from_string(self._templates["clib-implementation"]) + implementations = [] + other = set() + for c_func, recipe in zip(headers.funcs, headers.recipes): + msg = f" scaffolding {c_func.name!r} implementation" + _LOGGER.debug(msg) + body, bases = self._scaffold_body(c_func, recipe) + implementations.append( + template.render(declaration=c_func.declaration(),body=body)) + other |= bases + implementations = "\n\n".join(implementations) + str_utils = "copyString" in implementations + + if not headers.base: + # main CLib file receives references to all cabinets + other = [kk for kk in self._clib_bases if kk] + includes = [] + for obj in [headers.base] + list(other): + includes += self._config.includes[obj] + + template = loader.from_string(self._templates["clib-source-file"]) + output = template.render( + name=filename.stem, implementations=implementations, + prefix=headers.prefix, base=headers.base, docstring=headers.docstring, + includes=includes, other=other, str_utils=str_utils) + + out = Path(self._out_dir) / "src" / "clib_experimental" / filename.name + msg = f" writing {filename.name!r}" + _LOGGER.info(msg) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(output + "\n") + + def resolve_tags(self, headers_files: list[HeaderFile]) -> None: + """Resolve doxygen tags.""" + def get_bases() -> tuple[list[str], list[str]]: + bases = set() + classes = set() + for headers in headers_files: + bases |= {headers.base} + for recipe in headers.recipes: + classes |= set([recipe.base] + recipe.parents + recipe.derived) + return sorted(bases), sorted(classes) + + self._clib_bases, classes = get_bases() + self._doxygen_tags = TagFileParser(classes) + + for headers in headers_files: + msg = f" resolving recipes in {headers.path.name!r}:" + _LOGGER.info(msg) + c_funcs = [] + for recipe in headers.recipes: + c_funcs.append(self._resolve_recipe(recipe)) + headers.funcs = c_funcs + + def generate_source(self, headers_files: list[HeaderFile]) -> None: + """Generate output.""" + self.resolve_tags(headers_files) + + for headers in headers_files: + self._write_header(headers) + self._write_implementation(headers) diff --git a/interfaces/sourcegen/sourcegen/clib/_Config.py b/interfaces/sourcegen/sourcegen/clib/_Config.py new file mode 100644 index 0000000000..1de5c5bee3 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/clib/_Config.py @@ -0,0 +1,47 @@ +"""Configuration data class used by CLib source generator.""" + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +from dataclasses import dataclass +from typing_extensions import Self + + +@dataclass(frozen=True) +class Config: + """Provides configuration info for the CLibSourceGenerator class""" + + ret_type_crosswalk = { + "bool": "int", + "void": "int", + "int": "int", + "size_t": "int", + "double": "double", + "shared_ptr": "int", + "string": "char*", + "vector": "double[]", + "vector": "int[]", + } + + prop_type_crosswalk = { + "bool": "int", + "int": "int", + "size_t": "int", + "double": "double", + "const double": "double", + "double*": "double*", + "double* const": "double*", + "const double* const": "const double*", + "const string&": "const char*", + "shared_ptr": "int", + "const shared_ptr": "int", + "const vector&": "const double*", + "const vector>&": "int[]", + } + + includes: dict[str, list[str]] + + @classmethod + def from_parsed(cls: Self, *, includes: dict[str, list[str]] | None = None) -> Self: + """Ensure that configurations are correct.""" + return cls(includes or {}) diff --git a/interfaces/sourcegen/sourcegen/clib/__init__.py b/interfaces/sourcegen/sourcegen/clib/__init__.py new file mode 100644 index 0000000000..cd19070a83 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/clib/__init__.py @@ -0,0 +1,4 @@ +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +from ._CLibSourceGenerator import CLibSourceGenerator diff --git a/interfaces/sourcegen/sourcegen/clib/config.yaml b/interfaces/sourcegen/sourcegen/clib/config.yaml new file mode 100644 index 0000000000..f5059f5830 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/clib/config.yaml @@ -0,0 +1,28 @@ +# Configuration for CLib code generation. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +# Ignore these files entirely: +ignore_files: [] + +# Ignore these specific functions: +ignore_funcs: {} + # ctsol_auto.yaml: [setTransport] + +# Cabinets with associated includes +includes: + "": + - cantera/base/global.h + Solution: + - cantera/base/Solution.h + Interface: + - cantera/base/Interface.h + ThermoPhase: + - cantera/thermo/ThermoFactory.h + Kinetics: + - cantera/kinetics/KineticsFactory.h + Transport: + - cantera/transport/TransportFactory.h + Func1: + - cantera/numerics/Func1Factory.h diff --git a/interfaces/sourcegen/sourcegen/clib/templates.yaml b/interfaces/sourcegen/sourcegen/clib/templates.yaml new file mode 100644 index 0000000000..c99511d095 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/clib/templates.yaml @@ -0,0 +1,368 @@ +# Definitions used for Jinja template replacement. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +clib-param: |- + @param{{ '[' + par.direction + ']' if par.direction }} {{ par.name }} + +clib-comment: |- + {{ brief }} + + {% for par in params %} + {{ par }} + {% endfor %} + {% if returns %} + {{ '@returns' + 12*' ' + returns }} + {% endif %} + {% if params or returns %} + + {% endif %} + {% if implements %} + {{ '@implements ' + implements }} + {% endif %} + {% if relates %} + {{ '@relates ' + ', '.join(relates) }} + {% endif %} + +clib-definition: |- + {{ annotations }} + {{ declaration }}; + +clib-reserved-parentHandle-h: |- + /** + * Return handle to parent of {{ base }} object. + * @param handle Handle to queried {{ base }} object. + * @returns Parent handle or -1 for exception handling. + */ + int {{ prefix }}_parentHandle(int handle); + +clib-reserved-cabinetSize-h: |- + /** + * Return size of {{ base }} storage. + * @returns Size or -1 for exception handling. + */ + int {{ prefix }}_cabinetSize(); + +clib-reserved-getCanteraError-h: |- + /** + * Get %Cantera error. + * @param[in] bufLen Length of reserved array. + * @param[out] buf String containing %Cantera error. + * @returns Actual length of string or -1 for exception handling. + */ + int {{ prefix }}_getCanteraError(int bufLen, char* buf); + +clib-reserved-resetStorage-h: |- + /** + * Delete all objects and erase mapping. + * @returns Zero if successful or -1 for exception handling. + */ + int {{ prefix }}_resetStorage(); + +clib-reserved-clearStorage-h: |- + /** + * Delete all objects with mapping preserved. + * @returns Zero if successful or -1 for exception handling. + */ + int {{ prefix }}_clearStorage(); + +clib-header-file: |- + /** + * {{ name.upper() }} - Experimental CLib %Cantera interface library. + * + * @file {{ name }}.cpp + * + {% if docstring %} + {% for line in docstring %} + * {{ line }} + {% endfor %} + * + {% endif %} + * This library of functions is designed to encapsulate %Cantera functionality + * and make it available for use in languages and applications other than C++. + * A set of library functions is provided that are declared "extern C". All + * %Cantera objects are stored and referenced by integers - no pointers are + * passed to or from the calling application. + * + * This file was generated by sourcegen. It will be re-generated by the + * %Cantera build process. Do not manually edit. + * + * @warning This module is an experimental part of the %Cantera API and + * may be changed or removed without notice. + */ + + // This file is part of Cantera. See License.txt in the top-level directory or + // at https://cantera.org/license.txt for license and copyright information. + + #ifndef {{ guard }} + #define {{ guard }} + + #ifdef __cplusplus + extern "C" { + #endif + + {{ declarations | indent(4) }} + + #ifdef __cplusplus + } + #endif + + #endif // {{ guard }} + +clib-function: |- + // function: {{ cxx_implements }} + try { + {% for line in lines %} + {{ line }} + {% endfor %} + {% if buffer %} + {% if buffer[0] %} + {{ buffer[0] }} = {{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% else %} + {{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% endif %} + {% if buffer[1] %} + {{ buffer[1] }} + {% endif %} + return {{ buffer[2] }}; + {% else %} + return {{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% endif %} + } catch (...) { + return handleAllExceptions({{ error[0] }}, {{ error[1] }}); + } + +clib-constructor: |- + // constructor: {{ cxx_implements }} + try { + {% for line in lines %} + {{ line }} + {% endfor %} + {% if uses %} + {% if handle %} + auto obj = {{ cxx_base }}Cabinet::at({{ handle }})->{{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% else %} + auto obj = {{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% endif %} + int id = {{ cxx_base }}Cabinet::add(obj); + // add all associated objects + {% for typ, getter in uses %} + if (obj->{{ getter }}()) { + {{ typ }}Cabinet::add(obj->{{ getter }}(), id); + } + {% endfor %} + return id; + {% else %} + {% if handle %} + return {{ cxx_base }}Cabinet::add({{ cxx_base }}Cabinet::at({{ handle }})->{{ cxx_name }}({{ ', '.join(cxx_args) }})); + {% else %} + return {{ cxx_base }}Cabinet::add({{ cxx_name }}({{ ', '.join(cxx_args) }})); + {% endif %} + {% endif %} + } catch (...) { + return handleAllExceptions({{ error[0] }}, {{ error[1] }}); + } + +clib-destructor: |- + // destructor + try { + {% if uses %} + auto obj = {{ cxx_base }}Cabinet::at({{ handle }}); + // remove all associated objects in reversed order + {% for typ, getter in uses[-1::-1] %} + if (obj->{{ getter }}()) { + int index = {{ typ }}Cabinet::index(*(obj->{{ getter }}()), {{ handle }}); + if (index >= 0) { + {{ typ }}Cabinet::del(index); + } + } + {% endfor %} + {% endif %} + {{ cxx_base }}Cabinet::del({{ handle }}); + return 0; + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-method: |- + // {{ what }}: {{ cxx_implements }} + try { + {% for line in lines %} + {{ line }} + {% endfor %} + {% if buffer %} + {% if buffer[0] %} + {{ buffer[0] }} = {{ cxx_base }}Cabinet::at({{ handle }})->{{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% else %} + {{ cxx_base }}Cabinet::at({{ handle }})->{{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% endif %} + {% if buffer[1] %} + {{ buffer[1] }} + {% endif %} + return {{ buffer[2] }}; + {% else %} + return {{ cxx_base }}Cabinet::at({{ handle }})->{{ cxx_name }}({{ ', '.join(cxx_args) }}); + {% endif %} + } catch (...) { + return handleAllExceptions({{ error[0] }}, {{ error[1] }}); + } + +clib-array-getter: |- + // getter: {{ cxx_implements }} + try { + auto& obj = {{ cxx_base }}Cabinet::at({{ handle }}); + {% if uses %} + if ({{ c_args[1] }} != obj->{{ uses[0][1] }}()) { + throw CanteraError("{{ c_func }}", + "Invalid output array size; expected size {} but received {}.", + obj->{{ uses[0][1] }}(), {{ c_args[1] }}); + } + {% else %} + // no size checking specified + {% endif %} + obj->{{ cxx_name }}({{ cxx_args[0] }}); + return 0; + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-array-setter: |- + // setter: {{ cxx_implements }} + try { + auto& obj = {{ cxx_base }}Cabinet::at({{ handle }}); + {% if uses %} + if ({{ c_args[1] }} != obj->{{ uses[0][1] }}()) { + throw CanteraError("{{ c_func }}", + "Invalid input array size; expected size {} but received {}.", + obj->{{ uses[0][1] }}(), {{ c_args[1] }}); + } + {% else %} + // no size checking specified + {% endif %} + obj->{{ cxx_name }}({{ cxx_args[0] }}); + return 0; + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-noop: |- + // no-op + return 0; + +clib-custom-code: |- + // {{ what }}: {{ cxx_implements }} + // ********** custom code begin ********** + {% for line in lines %} + {{ line }} + {% endfor %} + // *********** custom code end *********** + +clib-implementation: |- + {{ declaration }} + { + {{ body | indent(4) }} + } + +clib-reserved-parentHandle-cpp: |- + // reserved: {{ cxx_base }} cabinet parent + try { + return {{ cxx_base }}Cabinet::parent(handle); + } catch (...) { + return handleAllExceptions(-2, ERR); + } + +clib-reserved-cabinetSize-cpp: |- + // reserved: int Cabinet<{{ cxx_base }}>.size() + try { + return {{ cxx_base }}Cabinet::size(); + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-reserved-getCanteraError-cpp: |- + // reserved: string Application::Instance()->lastErrorMessage(); + try { + string err = Application::Instance()->lastErrorMessage(); + copyString(err, buf, bufLen); + return int(err.size()); + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-reserved-resetStorage-cpp: |- + // reserved: void Cabinet::reset() + try { + {% for base in cabinets %} + {{ base }}Cabinet::reset(); + {% endfor %} + return 0; + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-reserved-clearStorage-cpp: |- + // reserved: void Cabinet::clear() + try { + {% for base in cabinets %} + {{ base }}Cabinet::clear(); + {% endfor %} + return 0; + } catch (...) { + return handleAllExceptions(-1, ERR); + } + +clib-source-file: |- + /** + * {{ name.upper() }} - Experimental CLib %Cantera interface library. + * + * @file {{ name }}.cpp + * + {% if docstring %} + {% for line in docstring %} + * {{ line }} + {% endfor %} + * + {% endif %} + * This file was generated by sourcegen. It will be re-generated by the + * %Cantera build process. Do not manually edit. + * + * @warning This module is an experimental part of the %Cantera API and + * may be changed or removed without notice. + */ + + // This file is part of Cantera. See License.txt in the top-level directory or + // at https://cantera.org/license.txt for license and copyright information. + + #include "cantera/clib_experimental/{{ name }}.h" + #include "../clib/clib_utils.h" + + {% if str_utils %} + #include "cantera/base/stringUtils.h" + {% endif %} + {% for entry in includes %} + #include "{{ entry }}" + {% endfor %} + + using namespace Cantera; + + {% if base %} + // Define Cabinet<{{ base }}> (single-instance object) + typedef Cabinet<{{ base }}> {{ base }}Cabinet; + // Note: cabinet is already created by the traditional CLib interface + // template<> {{ base }}Cabinet* {{ base }}Cabinet::s_storage = 0; // initialized here + template<> {{ base }}Cabinet* {{ base }}Cabinet::s_storage; // temporary + + {% endif %} + {% if other %} + {% for entry in other %} + typedef Cabinet<{{ entry }}> {{ entry }}Cabinet; + template<> {{ entry }}Cabinet* {{ entry }}Cabinet::s_storage; // initialized elsewhere + + {% endfor %} + {% endif %} + extern "C" { + + {{ implementations | indent(4) }} + + } // extern "C" diff --git a/interfaces/sourcegen/sourcegen/csharp/_CSharpSourceGenerator.py b/interfaces/sourcegen/sourcegen/csharp/_CSharpSourceGenerator.py index 2e2548aa9b..fd875b6faa 100644 --- a/interfaces/sourcegen/sourcegen/csharp/_CSharpSourceGenerator.py +++ b/interfaces/sourcegen/sourcegen/csharp/_CSharpSourceGenerator.py @@ -4,7 +4,6 @@ from pathlib import Path import sys import logging -from typing import List, Dict import re from jinja2 import Environment, BaseLoader @@ -21,8 +20,18 @@ class CSharpSourceGenerator(SourceGenerator): """The SourceGenerator for scaffolding C# files for the .NET interface""" + def __init__(self, out_dir: str, config: dict, templates: dict) -> None: + if not out_dir: + _logger.critical("Non-empty string identifying output path required.") + sys.exit(1) + self._out_dir = Path(out_dir) + + # use the typed config + self._config = Config.from_parsed(**config) + self._templates = templates + def _get_property_text(self, clib_area: str, c_name: str, cs_name: str, - known_funcs: Dict[str, CsFunc]) -> str: + known_funcs: dict[str, CsFunc]) -> str: getter = known_funcs.get(clib_area + "_" + c_name) if getter: @@ -57,16 +66,6 @@ def _get_property_text(self, clib_area: str, c_name: str, cs_name: str, _logger.critical(f"Unable to scaffold properties of type {prop_type!r}!") sys.exit(1) - def __init__(self, out_dir: str, config: dict, templates: dict): - if not out_dir: - _logger.critical("Non-empty string identifying output path required.") - sys.exit(1) - self._out_dir = Path(out_dir) - - # use the typed config - self._config = Config.from_parsed(**config) - self._templates = templates - def _get_wrapper_class_name(self, clib_area: str) -> str: return self._config.class_crosswalk[clib_area] @@ -160,7 +159,7 @@ def _write_file(self, file_name: str, template_name: str, **kwargs) -> None: self._out_dir.joinpath(file_name).write_text(contents, encoding="utf-8") - def _scaffold_interop(self, header_file_path: Path, cs_funcs: List[CsFunc]): + def _scaffold_interop(self, header_file_path: Path, cs_funcs: list[CsFunc]) -> None: template = _loader.from_string(self._templates["csharp-interop-func"]) function_list = [ template.render(unsafe=func.unsafe(), declaration=func.declaration()) @@ -170,7 +169,8 @@ def _scaffold_interop(self, header_file_path: Path, cs_funcs: List[CsFunc]): self._write_file( file_name, "csharp-scaffold-interop", cs_functions=function_list) - def _scaffold_handles(self, header_file_path: Path, handles: Dict[str, str]): + def _scaffold_handles( + self, header_file_path: Path, handles: dict[str, str]) -> None: template = _loader.from_string(self._templates["csharp-base-handle"]) handle_list = [ template.render(class_name=key, release_func_name=val) @@ -180,7 +180,7 @@ def _scaffold_handles(self, header_file_path: Path, handles: Dict[str, str]): self._write_file( file_name, "csharp-scaffold-handles", cs_handles=handle_list) - def _scaffold_derived_handles(self): + def _scaffold_derived_handles(self) -> None: template = _loader.from_string(self._templates["csharp-derived-handle"]) handle_list = [ template.render(derived_class_name=key, base_class_name=val) @@ -190,8 +190,8 @@ def _scaffold_derived_handles(self): self._write_file( file_name, "csharp-scaffold-handles", cs_handles=handle_list) - def _scaffold_wrapper_class(self, clib_area: str, props: Dict[str, str], - known_funcs: Dict[str, CsFunc]): + def _scaffold_wrapper_class(self, clib_area: str, props: dict[str, str], + known_funcs: dict[str, CsFunc]) -> None: property_list = [ self._get_property_text(clib_area, c_name, cs_name, known_funcs) for c_name, cs_name in props.items()] @@ -204,10 +204,10 @@ def _scaffold_wrapper_class(self, clib_area: str, props: Dict[str, str], wrapper_class_name=wrapper_class_name, handle_class_name=handle_class_name, cs_properties=property_list) - def generate_source(self, headers_files: List[HeaderFile]): + def generate_source(self, headers_files: list[HeaderFile]) -> None: self._out_dir.mkdir(parents=True, exist_ok=True) - known_funcs: Dict[str, List[CsFunc]] = {} + known_funcs: dict[str, list[CsFunc]] = {} for header_file in headers_files: cs_funcs = list(map(self._convert_func, header_file.funcs)) @@ -225,5 +225,5 @@ def generate_source(self, headers_files: List[HeaderFile]): self._scaffold_derived_handles() - for (clib_area, props) in self._config.wrapper_classes.items(): + for clib_area, props in self._config.wrapper_classes.items(): self._scaffold_wrapper_class(clib_area, props, known_funcs) diff --git a/interfaces/sourcegen/sourcegen/csharp/_Config.py b/interfaces/sourcegen/sourcegen/csharp/_Config.py index 3d525d841d..4a0b2221d3 100644 --- a/interfaces/sourcegen/sourcegen/csharp/_Config.py +++ b/interfaces/sourcegen/sourcegen/csharp/_Config.py @@ -2,12 +2,12 @@ # at https://cantera.org/license.txt for license and copyright information. from dataclasses import dataclass -from typing import Dict +from typing_extensions import Self @dataclass(frozen=True) class Config: - """Provides configuration info for the CSharpSourceGenerator class""" + """Provides configuration info for the CSharpSourceGenerator class.""" ret_type_crosswalk = { @@ -24,17 +24,19 @@ class Config: } # These we load from the parsed YAML config file - class_crosswalk: Dict[str, str] + class_crosswalk: dict[str, str] - class_accessors: Dict[str, str] + class_accessors: dict[str, str] - derived_handles: Dict[str, str] + derived_handles: dict[str, str] - wrapper_classes: Dict[str, Dict[str, str]] + wrapper_classes: dict[str, dict[str, str]] @classmethod - def from_parsed(cls, *, - class_crosswalk=None, class_accessors=None, - derived_handles=None, wrapper_classes=None): + def from_parsed(cls: Self, *, + class_crosswalk: dict[str, str] | None = None, + class_accessors: dict[str, str] | None = None, + derived_handles: dict[str, str] | None = None, + wrapper_classes: dict[str, dict[str, str]] | None = None): return cls(class_crosswalk or {}, class_accessors or {}, derived_handles or {}, wrapper_classes or {}) diff --git a/interfaces/sourcegen/sourcegen/csharp/_dataclasses.py b/interfaces/sourcegen/sourcegen/csharp/_dataclasses.py index d7d53b01ee..0dd653762d 100644 --- a/interfaces/sourcegen/sourcegen/csharp/_dataclasses.py +++ b/interfaces/sourcegen/sourcegen/csharp/_dataclasses.py @@ -2,7 +2,6 @@ # at https://cantera.org/license.txt for license and copyright information. from dataclasses import dataclass -from typing import Union from .._helpers import with_unpack_iter from .._dataclasses import Func @@ -14,8 +13,8 @@ class CsFunc(Func): """Represents a C# interop method""" is_handle_release_func: bool - handle_class_name: Union[str, None] + handle_class_name: str | None - def unsafe(self): + def unsafe(self) -> bool: """Identify pointers within argument lists.""" return any(p.p_type.endswith("*") for p in self.arglist) diff --git a/interfaces/sourcegen/sourcegen/yaml/_YamlSourceGenerator.py b/interfaces/sourcegen/sourcegen/yaml/_YamlSourceGenerator.py new file mode 100644 index 0000000000..ab0aa981da --- /dev/null +++ b/interfaces/sourcegen/sourcegen/yaml/_YamlSourceGenerator.py @@ -0,0 +1,66 @@ +""" +Generator for YAML output. + +Used for illustration purposes: the `CLibSourceGenerator` is used to preprocess YAML +header specifications, which yields the `HeaderFile` objects used by this source +generator. +""" + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +from pathlib import Path +import logging + +from jinja2 import Environment, BaseLoader + +from .._dataclasses import HeaderFile, CFunc +from .._SourceGenerator import SourceGenerator + + +_LOGGER = logging.getLogger() + +class YamlSourceGenerator(SourceGenerator): + """The SourceGenerator for generating CLib.""" + + def __init__(self, out_dir: str, config: dict, templates: dict) -> None: + self._out_dir = out_dir or None + if self._out_dir is not None: + self._out_dir = Path(out_dir) + self._out_dir.mkdir(parents=True, exist_ok=True) + self._config = config + self._templates = templates + + def _write_yaml(self, headers: HeaderFile) -> None: + """Parse header file and generate YAML output.""" + loader = Environment(loader=BaseLoader, trim_blocks=True, lstrip_blocks=True) + + definition = loader.from_string(self._templates["yaml-definition"]) + declarations = [] + for c_func, recipe in zip(headers.funcs, headers.recipes): + msg = f" scaffolding {c_func.name!r} implementation" + _LOGGER.debug(msg) + implements = "" + if isinstance(c_func.implements, CFunc): + implements = c_func.implements.short_declaration() + declarations.append( + definition.render(c_func=c_func, + returns=c_func.returns, implements=implements, + relates=c_func.uses, what=recipe.what)) + + filename = headers.output_name(suffix=".yaml", auto="3") + template = loader.from_string(self._templates["yaml-file"]) + output = template.render(filename=filename.name, header_entries=declarations) + + out = Path(self._out_dir) / "yaml" / filename.name + msg = f" writing {filename.name!r}" + _LOGGER.info(msg) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(output + "\n") + + def generate_source(self, headers_files: list[HeaderFile]) -> None: + """Generate output.""" + for headers in headers_files: + msg = f" parsing functions in {headers.path.name!r}" + _LOGGER.info(msg) + self._write_yaml(headers) diff --git a/interfaces/sourcegen/sourcegen/yaml/__init__.py b/interfaces/sourcegen/sourcegen/yaml/__init__.py new file mode 100644 index 0000000000..b3e39bec8c --- /dev/null +++ b/interfaces/sourcegen/sourcegen/yaml/__init__.py @@ -0,0 +1,4 @@ +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +from ._YamlSourceGenerator import YamlSourceGenerator diff --git a/interfaces/sourcegen/sourcegen/yaml/config.yaml b/interfaces/sourcegen/sourcegen/yaml/config.yaml new file mode 100644 index 0000000000..73adc9bbf5 --- /dev/null +++ b/interfaces/sourcegen/sourcegen/yaml/config.yaml @@ -0,0 +1,10 @@ +# Configuration for YAML output. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +# Ignore these files entirely: +ignore_files: [] + +# Ignore these specific functions: +ignore_funcs: {} diff --git a/interfaces/sourcegen/sourcegen/yaml/templates.yaml b/interfaces/sourcegen/sourcegen/yaml/templates.yaml new file mode 100644 index 0000000000..626690e6ce --- /dev/null +++ b/interfaces/sourcegen/sourcegen/yaml/templates.yaml @@ -0,0 +1,35 @@ +# Definitions used for Jinja template replacement. + +# This file is part of Cantera. See License.txt in the top-level directory or +# at https://cantera.org/license.txt for license and copyright information. + +yaml-definition: |- + {{ c_func.name }}: + description: {{ c_func.brief }} + what: {{ what }} + {% if c_func.arglist %} + parameters: + {% for par in c_func.arglist %} + {{ par.name }}: {{ par.description }} + {% endfor %} + {% endif %} + declaration: {{ c_func.declaration() }} + {% if c_func.returns %} + {{ 'returns: ' + c_func.returns }} + {% endif %} + +yaml-file: |- + # @file {{ filename }} + # + # This file was generated by sourcegen. It will be re-generated by the + # Cantera build process. Do not manually edit. + # + # @warning This module is an experimental part of the Cantera API and + # may be changed or removed without notice. + + # This file is part of Cantera. See License.txt in the top-level directory or + # at https://cantera.org/license.txt for license and copyright information. + + {% for header_entry in header_entries %} + {{ header_entry }} + {% endfor %} diff --git a/src/SConscript b/src/SConscript index 004180aac3..d958e0a515 100644 --- a/src/SConscript +++ b/src/SConscript @@ -39,6 +39,8 @@ libs = [('base', ['cpp'], baseSetup), ('zeroD', ['cpp'], defaultSetup), ('clib', ['cpp'], defaultSetup), ] +if env['clib_experimental']: + libs.append(('clib_experimental', ['cpp'], defaultSetup)) localenv = env.Clone() localenv.Prepend(CPPPATH=[Dir('#include'), Dir('.')]) diff --git a/src/clib_experimental/.gitignore b/src/clib_experimental/.gitignore new file mode 100644 index 0000000000..ce1da4c53c --- /dev/null +++ b/src/clib_experimental/.gitignore @@ -0,0 +1 @@ +*.cpp diff --git a/test/SConscript b/test/SConscript index 823ccc13a9..91709ce9d5 100644 --- a/test/SConscript +++ b/test/SConscript @@ -209,6 +209,8 @@ def addPythonTest(testname, subset): # Instantiate tests addTestProgram('clib', 'clib') +if localenv['clib_experimental']: + addTestProgram('clib_experimental', 'clib-experimental') addTestProgram('equil', 'equil') addTestProgram('general', 'general') addTestProgram('kinetics', 'kinetics') diff --git a/test/clib_experimental/test_clib3.cpp b/test/clib_experimental/test_clib3.cpp new file mode 100644 index 0000000000..5cb1672799 --- /dev/null +++ b/test/clib_experimental/test_clib3.cpp @@ -0,0 +1,304 @@ +#include +#include +#include + +#include "cantera/core.h" +#include "cantera/clib/clib_defs.h" +#include "cantera/clib_experimental/ct3.h" +#include "cantera/clib_experimental/ctsol3.h" +#include "cantera/clib_experimental/ctthermo3.h" +#include "cantera/clib_experimental/ctkin3.h" +#include "cantera/clib_experimental/cttrans3.h" + +using namespace Cantera; +using ::testing::HasSubstr; + +string reportError() +{ + vector output_buf; + int buflen = ct3_getCanteraError(0, output_buf.data()); + output_buf.resize(buflen); + ct3_getCanteraError(buflen, output_buf.data()); + return string(output_buf.data()); +} + +TEST(ct3, cabinet_exceptions) +{ + sol3_newSolution("h2o2.yaml", "ohmech", "default"); + sol3_name(999, 0, 0); + + string err = reportError(); + EXPECT_THAT(err, HasSubstr("Index 999 out of range.")); + + sol3_thermo(998); + err = reportError(); + EXPECT_THAT(err, HasSubstr("Index 998 out of range.")); + + int ret = sol3_del(997); + ASSERT_EQ(ret, -1); + err = reportError(); + EXPECT_THAT(err, HasSubstr("Index 997 out of range.")); + + int ref = sol3_newSolution("h2o2.yaml", "ohmech", "default"); + sol3_del(ref); + int thermo = sol3_thermo(ref); + EXPECT_EQ(thermo, -2); + err = reportError(); + EXPECT_THAT(err, HasSubstr("has been deleted.")); + + ct3_resetStorage(); + ret = sol3_del(0); + ASSERT_EQ(ret, -1); + err = reportError(); + EXPECT_THAT(err, HasSubstr("Index 0 out of range.")); +} + +TEST(ct3, new_solution) +{ + ct3_resetStorage(); + + string name = "ohmech"; + int ref = sol3_newSolution("h2o2.yaml", name.c_str(), "default"); + ASSERT_EQ(ref, 0); + + ASSERT_EQ(sol3_cabinetSize(), 1); + ASSERT_EQ(thermo3_cabinetSize(), 1); + ASSERT_EQ(kin3_cabinetSize(), 1); + + int buflen = sol3_name(ref, 0, 0); // includes \0 + ASSERT_EQ(buflen, int(name.size() + 1)); + + int thermo = sol3_thermo(ref); + ASSERT_EQ(thermo3_parentHandle(thermo), ref); + + vector buf(buflen); + sol3_name(ref, buflen, buf.data()); + string solName(buf.data()); + ASSERT_EQ(solName, name); +} + +TEST(ct3, sol3_objects) +{ + ct3_resetStorage(); + + int ref = sol3_newSolution("gri30.yaml", "gri30", "none"); + ASSERT_EQ(ref, 0); + ASSERT_EQ(thermo3_cabinetSize(), 1); // one ThermoPhase object + + int ref2 = sol3_newSolution("h2o2.yaml", "ohmech", "default"); + ASSERT_EQ(ref2, 1); + ASSERT_EQ(thermo3_cabinetSize(), 2); // two ThermoPhase objects + + int thermo = sol3_thermo(ref); + ASSERT_EQ(thermo3_parentHandle(thermo), ref); + + int thermo2 = sol3_thermo(ref2); + ASSERT_EQ(thermo2, 1); // references stored object with index '1' + ASSERT_EQ(thermo3_nSpecies(thermo2), 10u); + ASSERT_EQ(thermo3_parentHandle(thermo2), ref2); + + int kin = sol3_kinetics(ref); + + int kin2 = sol3_kinetics(ref2); + ASSERT_EQ(kin2, 1); + ASSERT_EQ(kin3_nReactions(kin2), 29u); + ASSERT_EQ(kin3_parentHandle(kin2), ref2); + ASSERT_EQ(kin3_parentHandle(kin), ref); + + int trans = sol3_transport(ref); + ASSERT_EQ(trans3_parentHandle(trans), ref); + + int trans2 = sol3_transport(ref2); + ASSERT_EQ(trans2, 1); + int buflen = trans3_transportModel(trans2, 0, 0); + vector buf(buflen); + trans3_transportModel(trans2, buflen, buf.data()); + string trName(buf.data()); + ASSERT_EQ(trName, "mixture-averaged"); + ASSERT_EQ(trans3_parentHandle(trans2), ref2); + + sol3_del(ref2); + int nsp = thermo3_nSpecies(thermo2); + ASSERT_EQ(nsp, ERR); + string err = reportError(); + EXPECT_THAT(err, HasSubstr("has been deleted.")); + + nsp = thermo3_nSpecies(thermo2); + ASSERT_EQ(nsp, ERR); + err = reportError(); + EXPECT_THAT(err, HasSubstr("has been deleted.")); + + trans2 = sol3_setTransportModel(ref, "mixture-averaged"); + ASSERT_EQ(trans2, 2); + buflen = trans3_transportModel(trans2, 0, 0); + buf.resize(buflen); + trans3_transportModel(trans2, buflen, buf.data()); + trName = buf.data(); + ASSERT_EQ(trName, "mixture-averaged"); +} + +TEST(ct3, new_interface) +{ + ct3_resetStorage(); + + int sol = sol3_newSolution("ptcombust.yaml", "gas", "none"); + ASSERT_EQ(sol, 0); + + vector adj{sol}; + int surf = sol3_newInterface("ptcombust.yaml", "Pt_surf", 1, adj.data()); + ASSERT_EQ(surf, 1); + + int ph_surf = sol3_thermo(surf); + int buflen = sol3_name(ph_surf, 0, 0) + 1; // include \0 + vector buf(buflen); + sol3_name(ph_surf, buflen, buf.data()); + string solName(buf.data()); + ASSERT_EQ(solName, "Pt_surf"); + + int kin3_surf = sol3_kinetics(surf); + buflen = kin3_kineticsType(kin3_surf, 0, 0) + 1; // include \0 + buf.resize(buflen); + kin3_kineticsType(ph_surf, buflen, buf.data()); + string kinType(buf.data()); + ASSERT_EQ(kinType, "surface"); +} + +TEST(ct3, new_interface_auto) +{ + ct3_resetStorage(); + + vector adj; + int surf = sol3_newInterface("ptcombust.yaml", "Pt_surf", 0, adj.data()); + ASSERT_EQ(surf, 0); + + ASSERT_EQ(sol3_nAdjacent(surf), 1u); + int gas = sol3_adjacent(surf, 0); + ASSERT_EQ(gas, 1); + + int buflen = sol3_name(gas, 0, 0) + 1; // include \0 + vector buf(buflen); + sol3_name(gas, buflen, buf.data()); + string solName(buf.data()); + ASSERT_EQ(solName, "gas"); +} + +TEST(ct3, thermo) +{ + int ret; + int sol = sol3_newSolution("gri30.yaml", "gri30", "none"); + int thermo = sol3_thermo(sol); + ASSERT_GE(thermo, 0); + size_t nsp = thermo3_nSpecies(thermo); + ASSERT_EQ(nsp, 53u); + + ret = thermo3_setTemperature(thermo, 500); + ASSERT_EQ(ret, 0); + ret = thermo3_setPressure(thermo, 5 * 101325); + ASSERT_EQ(ret, 0); + ret = thermo3_setMoleFractionsByName(thermo, "CH4:1.0, O2:2.0, N2:7.52"); + ASSERT_EQ(ret, 0); + + ret = thermo3_equilibrate(thermo, "HP", "auto", 1e-9, 50000, 1000, 0); + ASSERT_EQ(ret, 0); + double T = thermo3_temperature(thermo); + ASSERT_GT(T, 2200); + ASSERT_LT(T, 2300); + + size_t ns = thermo3_nSpecies(thermo); + vector work(ns); + vector X(ns); + thermo3_getMoleFractions(thermo, ns, X.data()); + + thermo3_getPartialMolarEnthalpies(thermo, ns, work.data()); + double prod = std::inner_product(X.begin(), X.end(), work.begin(), 0.0); + ASSERT_NEAR(prod, thermo3_enthalpy_mole(thermo), 1e-6); + + thermo3_getPartialMolarEntropies(thermo, ns, work.data()); + prod = std::inner_product(X.begin(), X.end(), work.begin(), 0.0); + ASSERT_NEAR(prod, thermo3_entropy_mole(thermo), 1e-6); + + thermo3_getPartialMolarIntEnergies(thermo, ns, work.data()); + prod = std::inner_product(X.begin(), X.end(), work.begin(), 0.0); + ASSERT_NEAR(prod, thermo3_intEnergy_mole(thermo), 1e-6); + + thermo3_getPartialMolarCp(thermo, ns, work.data()); + prod = std::inner_product(X.begin(), X.end(), work.begin(), 0.0); + ASSERT_NEAR(prod, thermo3_cp_mole(thermo), 1e-6); + + thermo3_getPartialMolarVolumes(thermo, ns, work.data()); + prod = std::inner_product(X.begin(), X.end(), work.begin(), 0.0); + ASSERT_NEAR(prod, 1./thermo3_molarDensity(thermo), 1e-6); +} + +TEST(ct3, kinetics) +{ + int sol0 = sol3_newSolution("gri30.yaml", "gri30", "none"); + int thermo = sol3_thermo(sol0); + int kin = sol3_kinetics(sol0); + ASSERT_GE(kin, 0); + + size_t nr = kin3_nReactions(kin); + ASSERT_EQ(nr, 325u); + + thermo3_equilibrate(thermo, "HP", "auto", 1e-9, 50000, 1000, 0); + double T = thermo3_temperature(thermo); + thermo3_setTemperature(thermo, T - 200); + + auto sol = newSolution("gri30.yaml", "gri30", "none"); + auto phase = sol->thermo(); + auto kinetics = sol->kinetics(); + + phase->equilibrate("HP"); + ASSERT_NEAR(T, phase->temperature(), 1e-2); + phase->setTemperature(T - 200); + + vector c_ropf(nr); + kin3_getFwdRatesOfProgress(kin, 325, c_ropf.data()); + vector cpp_ropf(nr); + kinetics->getFwdRatesOfProgress(cpp_ropf.data()); + + for (size_t n = 0; n < nr; n++) { + ASSERT_NEAR(cpp_ropf[n], c_ropf[n], 1e-6); + } +} + +TEST(ct3, transport) +{ + int sol0 = sol3_newSolution("gri30.yaml", "gri30", "default"); + int thermo = sol3_thermo(sol0); + int tran = sol3_transport(sol0); + + size_t nsp = thermo3_nSpecies(thermo); + vector c_dkm(nsp); + int ret = trans3_getMixDiffCoeffs(tran, 53, c_dkm.data()); + ASSERT_EQ(ret, 0); + + vector cpp_dkm(nsp); + auto sol = newSolution("gri30.yaml", "gri30"); + auto transport = sol->transport(); + transport->getMixDiffCoeffs(cpp_dkm.data()); + + for (size_t n = 0; n < nsp; n++) { + ASSERT_NEAR(cpp_dkm[n], c_dkm[n], 1e-10); + } +} + + +int main(int argc, char** argv) +{ + printf("Running main() from test_clib3.cpp\n"); + testing::InitGoogleTest(&argc, argv); + make_deprecation_warnings_fatal(); + printStackTraceOnSegfault(); + Cantera::CanteraError::setStackTraceDepth(20); + /* commented code below is relevant to future PRs */ + // vector fileNames = {"gtest-freeflame.yaml", "gtest-freeflame.h5"}; + // for (const auto& fileName : fileNames) { + // if (std::ifstream(fileName).good()) { + // std::remove(fileName.c_str()); + // } + // } + int result = RUN_ALL_TESTS(); + appdelete(); + return result; +} diff --git a/test/clib_experimental/test_ctfunc3.cpp b/test/clib_experimental/test_ctfunc3.cpp new file mode 100644 index 0000000000..f46cd56309 --- /dev/null +++ b/test/clib_experimental/test_ctfunc3.cpp @@ -0,0 +1,305 @@ +#include +#include +#include + +#include "cantera/core.h" +#include "cantera/clib_experimental/ctfunc3.h" +#include "cantera/numerics/Func1Factory.h" + +using namespace Cantera; + + +TEST(ctfunc3, invalid) +{ + // exceptions return -1 + ASSERT_EQ(func13_newBasic("spam", 0.), -2); + ASSERT_EQ(func13_newAdvanced("eggs", 0, NULL), -2); +} + +TEST(ctfunc3, sin) +{ + double omega = 2.1; + int fcn = func13_newBasic("sin", omega); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5)); + + int dfcn = func13_newDerivative(fcn); + EXPECT_DOUBLE_EQ(func13_eval(dfcn, 0.5), omega * cos(omega * 0.5)); + + int buflen = func13_write(fcn, "x", 0, 0); + vector buf(buflen); + func13_write(fcn, "x", buflen, buf.data()); + string rep(buf.data()); + ASSERT_EQ(rep, "\\sin(2.1x)"); +} + +TEST(ctfunc3, cos) +{ + double omega = 2.; + int fcn = func13_newBasic("cos", omega); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), cos(omega * 0.5)); + + int dfcn = func13_newDerivative(fcn); + EXPECT_DOUBLE_EQ(func13_eval(dfcn, 0.5), -omega * sin(omega * 0.5)); +} + +TEST(ctfunc3, exp) +{ + double omega = 2.; + int fcn = func13_newBasic("exp", omega); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), exp(omega * 0.5)); + + int dfcn = func13_newDerivative(fcn); + EXPECT_DOUBLE_EQ(func13_eval(dfcn, 0.5), omega * exp(omega * 0.5)); +} + +TEST(ctfunc3, log) +{ + double omega = 2.; + int fcn = func13_newBasic("log", omega); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 1. / omega), 0.); + + int dfcn = func13_newDerivative(fcn); + EXPECT_DOUBLE_EQ(func13_eval(dfcn, .5), omega / .5); +} + +TEST(ctfunc3, pow) +{ + double exp = .5; + int fcn = func13_newBasic("pow", exp); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), pow(0.5, exp)); + + int dfcn = func13_newDerivative(fcn); + EXPECT_DOUBLE_EQ(func13_eval(dfcn, 0.5), exp * pow(0.5, exp - 1)); +} + +TEST(ctfunc3, constant) +{ + double a = .5; + int fcn = func13_newBasic("constant", a); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), a); + + int dfcn = func13_newDerivative(fcn); + EXPECT_DOUBLE_EQ(func13_eval(dfcn, .5), 0.); +} + +TEST(ctfunc3, tabulated_linear) +{ + vector params = {0., 1., 2., 1., 0., 1.}; + + int fcn = func13_newAdvanced("tabulated-linear", params.size(), params.data()); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), 0.5); + + // exceptions return -1 + EXPECT_EQ(func13_newAdvanced("tabulated-linear", 5, params.data()), -2); +} + +TEST(ctfunc3, tabulated_previous) +{ + vector params = {0., 1., 2., 1., 0., 1.}; + + int fcn = func13_newAdvanced("tabulated-previous", params.size(), params.data()); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), 1.); +} + +TEST(ctfunc3, poly) +{ + double a0 = .5; + double a1 = .25; + double a2 = .125; + vector params = {a2, a1, a0}; + int fcn = func13_newAdvanced("polynomial3", params.size(), params.data()); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, .5), (a2 * .5 + a1) * .5 + a0); + + params = {1, 0, -2.2, 3.1}; + fcn = func13_newAdvanced("polynomial3", params.size(), params.data()); + int buflen = func13_write(fcn, "x", 0, 0); + vector buf(buflen); + func13_write(fcn, "x", buflen, buf.data()); + string rep(buf.data()); + ASSERT_EQ(rep, "x^3 - 2.2x + 3.1"); +} + +TEST(ctfunc3, Fourier) +{ + double a0 = .5; + double a1 = .25; + double b1 = .125; + double omega = 2.; + vector params = {a0, a1, omega, b1}; + int fcn = func13_newAdvanced("Fourier", params.size(), params.data()); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ( + func13_eval(fcn, .5), .5 * a0 + a1 * cos(omega * .5) + b1 * sin(omega * .5)); +} + +TEST(ctfunc3, Gaussian) +{ + double A = .5; + double t0 = .6; + double fwhm = .25; + vector params = {A, t0, fwhm}; + int fcn = func13_newAdvanced("Gaussian", params.size(), params.data()); + ASSERT_GE(fcn, 0); + double tau = fwhm / (2. * sqrt(log(2.))); + double x = - t0 / tau; + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.), A * exp(-x * x)); + + // exceptions return -1 + EXPECT_EQ(func13_newAdvanced("Gaussian", 2, params.data()), -2); +} + +TEST(ctfunc3, Arrhenius) +{ + double A = 38.7; + double b = 2.7; + double E = 2.619184e+07 / GasConstant; + vector params = {A, b, E}; + int fcn = func13_newAdvanced("Arrhenius", params.size(), params.data()); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 1000.), A * pow(1000., b) * exp(-E/1000.)); +} + +TEST(ctmath, invalid) +{ + // exceptions return -1 + int fcn0 = func13_newBasic("sin", 1.); + int fcn1 = func13_newBasic("cos", 1.); + ASSERT_EQ(func13_newCompound("foo", fcn0, fcn1), -2); + ASSERT_EQ(func13_newModified("bar", fcn0, 0.), -2); +} + +TEST(ctmath, sum) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + int fcn1 = func13_newBasic("cos", omega); + int fcn = func13_newCompound("sum", fcn0, fcn1); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5) + cos(omega * 0.5)); + int fcn2 = func13_newSum(fcn0, fcn1); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), func13_eval(fcn2, 0.5)); +} + +TEST(ctmath, diff) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + int fcn1 = func13_newBasic("cos", omega); + int fcn = func13_newCompound("diff", fcn0, fcn1); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5) - cos(omega * 0.5)); + int fcn2 = func13_newDiff(fcn0, fcn1); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), func13_eval(fcn2, 0.5)); +} + +TEST(ctmath, prod) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + int fcn1 = func13_newBasic("cos", omega); + int fcn = func13_newCompound("product", fcn0, fcn1); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5) * cos(omega * 0.5)); + int fcn2 = func13_newProd(fcn0, fcn1); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), func13_eval(fcn2, 0.5)); +} + +TEST(ctmath, ratio) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + int fcn1 = func13_newBasic("cos", omega); + int fcn = func13_newCompound("ratio", fcn0, fcn1); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5) / cos(omega * 0.5)); + int fcn2 = func13_newRatio(fcn0, fcn1); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), func13_eval(fcn2, 0.5)); +} + +TEST(ctmath, composite) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + int fcn1 = func13_newBasic("cos", omega); + int fcn = func13_newCompound("composite", fcn0, fcn1); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * cos(omega * 0.5))); +} + +TEST(ctmath, times_constant) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + double A = 1.234; + int fcn = func13_newModified("times-constant", fcn0, A); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5) * A); +} + +TEST(ctmath, plus_constant) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + double A = 1.234; + int fcn = func13_newModified("plus-constant", fcn0, A); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), sin(omega * 0.5) + A); +} + +TEST(ctmath, periodic) +{ + double omega = 2.; + int fcn0 = func13_newBasic("sin", omega); + double A = 1.234; + int fcn = func13_newModified("periodic", fcn0, A); + ASSERT_GE(fcn, 0); + EXPECT_DOUBLE_EQ(func13_eval(fcn, 0.5), func13_eval(fcn, 0.5 + A)); +} + +TEST(ctmath, generic) +{ + // Composite function reported in issue #752 + + vector params = {0., 0., 1., 1.}; + int fs = func13_newAdvanced("Fourier", params.size(), params.data()); // sin(x) + auto func13_s = newFunc1("sin", 1.); + EXPECT_DOUBLE_EQ(func13_eval(fs, 0.5), func13_s->eval(0.5)); + + int fs2 = func13_newCompound("product", fs, fs); // (sin(x)^2) + auto func13_s2 = newFunc1("product", func13_s, func13_s); + EXPECT_DOUBLE_EQ(func13_eval(fs2, 0.5), func13_s2->eval(0.5)); + + double one = 1.; + int fs1 = func13_newAdvanced("polynomial3", 1, &one); // 1. + auto func13_s1 = newFunc1("constant", 1.); + EXPECT_DOUBLE_EQ(func13_eval(fs1, 0.5), func13_s1->eval(0.5)); + EXPECT_DOUBLE_EQ(func13_eval(fs1, 0.5), 1.); + + int fs2_1 = func13_newCompound("diff", fs1, fs2); // 1-(sin(x))^2 + auto func13_s2_1 = newFunc1("diff", func13_s1, func13_s2); + EXPECT_DOUBLE_EQ(func13_eval(fs2_1, 0.5), func13_s2_1->eval(0.5)); + + vector p_arr = {1., .5, 0.}; + int fs3 = func13_newAdvanced("Arrhenius", 3, p_arr.data()); // sqrt function + auto func13_s3 = newFunc1("Arrhenius", p_arr); + EXPECT_DOUBLE_EQ(func13_eval(fs3, 0.5), func13_s3->eval(0.5)); + + // overall composite function + int fs4 = func13_newCompound("composite", fs3, fs2_1); // sqrt(1-(sin(x))^2) + auto func13_s4 = newFunc1("composite", func13_s3, func13_s2_1); + EXPECT_DOUBLE_EQ(func13_eval(fs4, 0.5), func13_s4->eval(0.5)); + + // an easier equivalent expression (using trigonometry) + auto func13_s5 = newFunc1("cos", 1.); // missing the absolute value + EXPECT_DOUBLE_EQ(func13_eval(fs4, 0.5), func13_s5->eval(0.5)); + EXPECT_DOUBLE_EQ(func13_eval(fs4, 3.5), -func13_s5->eval(3.5)); +}