diff --git a/.gitattributes b/.gitattributes index 65403ce2..291efed8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,7 @@ vaft/data/wheel_samples/** text=auto eol=lf # POSIX shell scripts must keep LF even in a Windows checkout. *.sh text eol=lf + +# GACODE fixed-format text fixtures are compared field-by-field against a +# real NEO run, so a CRLF checkout must not change them. +test/data/gacode/** text=auto eol=lf diff --git a/README.md b/README.md index 334d828c..48e88909 100755 --- a/README.md +++ b/README.md @@ -214,8 +214,17 @@ export CHEASEHOME=/path/to/chease export EFITHOME=/path/to/efit export TESHOME=/path/to/tes export NUBEAMHOME=/path/to/nubeam +export GACODEHOME=/path/to/gacode +export GACODE_PLATFORM=GFORTRAN_OSX_BREW ``` +`GACODEHOME` is the GACODE checkout itself: the suite builds in place, so there is no +separate prefix, and each member carries its own `bin` (`neo/bin/neo`). `GACODE_PLATFORM` +names the tag it was built with. VAFT sets GACODE's own `GACODE_ROOT` and +`GACODE_PLATFORM` for the subprocess from these rather than redefining them, and falls +back to `GACODE_ROOT` when `GACODEHOME` is unset. Build it through +[`external/gacode/`](external/gacode/) and verify with `python install/check_gacode.py`. + `NUBEAMHOME` also supplies the PREACT and ADAS reaction databases NUBEAM cannot run without, at `share/preact` and `share/adas`. VAFT builds NUBEAM through [`external/nubeam/`](external/nubeam/) rather than vendoring it: NTCC requires each diff --git a/docs/_guide/Formula_reference_neoclassical.md b/docs/_guide/Formula_reference_neoclassical.md new file mode 100644 index 00000000..373dff3d --- /dev/null +++ b/docs/_guide/Formula_reference_neoclassical.md @@ -0,0 +1,92 @@ +--- +title: "Formula reference: neoclassical" +author: VEST team +date: 2026-09-07 17:20 +category: guide +layout: post +permalink: /reference/formula/neoclassical/ +guide: + architecture: Generated per-function reference for vaft.formula.neoclassical, read from the standardized docstrings (issue 248). + prerequisites: None. + expected: Definition, units, conventions, validity, limitations and literature references for every public function of the submodule. +related: + api: [formula] +--- + +{% assign category = site.data.formula_catalog.categories | where: "name", "neoclassical" | first %} +{% assign entries = site.data.formula_catalog.formulas | where: "category", "neoclassical" %} + +This page is generated from the docstrings of +[`vaft/formula/neoclassical.py`](https://github.com/VEST-Tokamak/vaft/blob/develop/vaft/formula/neoclassical.py): +{{ entries.size }} public functions. The category overview and notation come from the module +docstring; every entry below is what `vaft.formula.describe("neoclassical.")` prints. +Back to the [formula reference index]({{ site.baseurl }}/reference/formula/). + +## Overview + +{{ category.overview }} + +{% if category.notation.size > 0 %} + + + {% for row in category.notation %} + {% endfor %} +
SymbolMeaningUnit
{{ row.symbol | escape }}{{ row.description | escape }}{{ row.unit | escape }}
+ +{% endif %}{% if category.conventions != "" %}{{ category.conventions }} + +{% endif %}## Functions + + + +{% for f in entries %} +### `{{ f.name }}` {#{{ f.name }}} + +

{{ f.name }}{{ f.signature }}{% if f.aliases.size > 0 %} — aliases {% for alias in f.aliases %}{{ alias }}{% unless forloop.last %}, {% endunless %}{% endfor %}{% endif %}

+ +{% if f.empirical or f.convention_sensitive or f.deprecated or f.shadowed_by %}

{% if f.empirical %}Empirical fit. {% endif %}{% if f.convention_sensitive %}Convention-sensitive. {% endif %}{% if f.deprecated %}Deprecated. {% endif %}{% if f.shadowed_by %}vaft.formula.{{ f.name }} resolves to the {{ f.shadowed_by }} copy; reach this one as vaft.formula.{{ f.category }}.{{ f.name }}.{% endif %}

+ +{% endif %}{{ f.summary }} + +{% if f.description != "" %}{{ f.description }} + +{% endif %}{% if f.parameters.size > 0 %} + + + {% for p in f.parameters %} + {% endfor %} +
ParameterTypeUnitDescription
{{ p.name }}{{ p.type }}{{ p.unit }}{{ p.description | markdownify }}
+ +{% endif %}{% if f.returns.size > 0 %} + + + {% for r in f.returns %} + {% endfor %} +
ReturnsTypeUnitDescription
{% if r.name %}{{ r.name }}{% endif %}{{ r.type }}{{ r.unit }}{{ r.description | markdownify }}
+ +{% endif %}{% for s in f.sections %}

{{ s.title }}.

+ +{{ s.text }} + +{% endfor %}{% if f.references.size > 0 %}

References.

+ +
    +{% for ref in f.references %}
  1. {{ ref.text | markdownify | remove: "

    " | remove: "

    " }}
  2. +{% endfor %}
+ +{% endif %}{% endfor %} + +## Refreshing this snapshot + +From a checkout of the `develop` branch, run: + +```bash +python -m vaft.formula.catalog --output /path/to/vaft-gh/_data/formula_catalog.yml +``` + +The snapshot records the SHA-256 of every `vaft/formula/*.py` source file; documentation +validation compares them when `VAFT_REGISTRY_SOURCE` points to the corresponding source checkout. +The same text is available offline as `vaft.formula.describe("")`, +`vaft.formula.search("")` and `vaft.formula.list_formulas(category="")`. diff --git a/external/gacode/README.md b/external/gacode/README.md new file mode 100644 index 00000000..59207fd6 --- /dev/null +++ b/external/gacode/README.md @@ -0,0 +1,70 @@ +# GACODE: build and verify + +GACODE is the General Atomics code suite for kinetic and transport modelling. +This directory builds it; `vaft.code.gacode` then runs it. NEO, the +drift-kinetic neoclassical solver, is the first backend VAFT drives; TGLF and +CGYRO share the same profile and runtime layer and are tracked in +[issue #553](https://github.com/VEST-Tokamak/vaft/issues/553). + +**The source is not here, deliberately.** VAFT owns the build recipe and the +adapter contract; the source stays external, obtained from +[gafusion/gacode](https://github.com/gafusion/gacode). Every script takes +`--gacode-root` naming a tree you already hold and writes nothing into the VAFT +checkout. + +**GACODE builds in place.** There is no separate installation prefix: the +executables land inside the source tree (`neo/src/neo`, launched through +`neo/bin/neo`). `$GACODEHOME` therefore points at the checkout itself, which is +why this code has no `/local` the way NUBEAM does. + +**macOS / Apple Silicon.** Linux and Windows are not covered here. None of this +runs in CI; the VAFT test suite passes with GACODE absent. + +| File | Purpose | +| --- | --- | +| `macos.sh` | Installs the Homebrew dependencies, builds the shared and `f2py` libraries and the requested suite members, and optionally runs the NEO `reg18` regression case. | + +## Usage + +```bash +bash external/gacode/macos.sh --gacode-root ~/git/gacode --check +export GACODEHOME=~/git/gacode +python install/check_gacode.py --source ~/git/gacode +``` + +## The environment contract, and why VAFT does not replace it + +GACODE's own build and run scripts read two variables: + +| Variable | Meaning | +| --- | --- | +| `GACODE_ROOT` | the suite tree | +| `GACODE_PLATFORM` | selects `platform/build/make.inc.$GACODE_PLATFORM` for the build and `platform/exec/exec.$GACODE_PLATFORM` for the run | + +VAFT adds `GACODEHOME` to match the `$XHOME` convention every other external +code in this repository uses (`GPECHOME`, `CHEASEHOME`, `EFITHOME`, +`NUBEAMHOME`), and **derives** `GACODE_ROOT` and `GACODE_PLATFORM` from it for +the subprocess rather than redefining them. A tree built here therefore stays +usable from a plain shell that sources `shared/bin/gacode_setup`, and +`vaft.code.gacode` accepts a pre-set `GACODE_ROOT` as a compatibility fallback +when `GACODEHOME` is unset. + +## Two failure modes worth knowing before you hit them + +**The launcher needs `pygacode` on `PYTHONPATH`.** `neo/bin/neo` shells out to +`neo_parse.py`, which imports `gacodeinput` from `f2py/pygacode`. When that +import fails the launcher does *not* stop -- it carries on, and NEO then aborts +with a Fortran runtime error about a missing `./input.neo.gen`, which points at +the wrong thing entirely. `vaft.code.gacode` always sets `PYTHONPATH` itself +for this reason. + +**`GACODE_PLATFORM` must match the build.** `neo/bin/neo` executes +`platform/exec/exec.$GACODE_PLATFORM`; an unset or wrong value fails deep inside +a shell script without naming the variable. `vaft.code.gacode` resolves it +explicitly and lists the available platforms when it cannot. + +## Verified + +Built against `gafusion/gacode` `6357db30` (2026-07-22) with Homebrew +gfortran 15.2 and Open MPI on macOS/arm64. The NEO `reg18` regression case +reproduces its shipped `out.neo.prec` value `0.12268957E+02` exactly. diff --git a/external/gacode/macos.sh b/external/gacode/macos.sh new file mode 100644 index 00000000..c8d83fac --- /dev/null +++ b/external/gacode/macos.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Build the GACODE suite (NEO first) natively on Apple Silicon macOS. +# +# Usage: +# bash external/gacode/macos.sh --gacode-root PATH [--codes neo,tglf] [--check] +# +# VAFT does not vendor the GACODE source. This script owns the reproducible +# build recipe and operates on a GACODE tree you already hold, named by +# --gacode-root. Unlike NUBEAM, GACODE builds in place: there is no separate +# installation prefix, so $GACODEHOME points at the checkout itself. +# +# GACODE's own build contract is $GACODE_ROOT plus $GACODE_PLATFORM, which +# selects platform/build/make.inc.$GACODE_PLATFORM. VAFT does not redefine +# either -- it sets both from $GACODEHOME rather than replacing them, so a tree +# built here stays usable from a plain shell with shared/bin/gacode_setup. +# +# macOS/Apple Silicon only. Linux and Windows are not covered here. + +set -euo pipefail +IFS=$'\n\t' + +GACODE_SOURCE="${GACODE_SOURCE_DIR:-}" +CODES="neo" +RUN_CHECK=0 + +usage() { + cat <<'EOF' +Usage: bash external/gacode/macos.sh --gacode-root PATH [--codes neo,tglf] [--check] + + --gacode-root PATH the GACODE source tree to build (or set GACODE_SOURCE_DIR) + --codes LIST comma-separated suite members to build; default "neo" + --check after building, run the NEO reg18 regression case + +Environment overrides: + GACODE_SOURCE_DIR=/absolute/path default for --gacode-root + GACODE_PLATFORM=NAME default GFORTRAN_OSX_BREW + +The build happens in place. Afterwards, export: + + export GACODEHOME= + +which is what vaft.code.gacode reads. Nothing is written into the VAFT checkout. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --gacode-root) GACODE_SOURCE="${2:-}" ; shift 2 ;; + --codes) CODES="${2:-}" ; shift 2 ;; + --check) RUN_CHECK=1 ; shift ;; + -h|--help) usage ; exit 0 ;; + *) echo "unknown argument: $1" >&2 ; usage >&2 ; exit 2 ;; + esac +done + +if [ -z "$GACODE_SOURCE" ]; then + echo "error: --gacode-root is required (or set GACODE_SOURCE_DIR)" >&2 + usage >&2 + exit 2 +fi + +GACODE_SOURCE="$(cd "$GACODE_SOURCE" && pwd -P)" + +for marker in Makefile shared/bin/gacode_setup platform/build neo/src; do + if [ ! -e "$GACODE_SOURCE/$marker" ]; then + echo "error: $GACODE_SOURCE is missing $marker, so it is not a GACODE tree" >&2 + exit 1 + fi +done + +if ! command -v brew >/dev/null 2>&1; then + echo "error: Homebrew is required. See https://brew.sh" >&2 + exit 1 +fi + +# gcc supplies gfortran; open-mpi supplies the mpif90 wrapper the makefiles call +# unconditionally, even for the serial build. fftw and netcdf are linked by the +# suite makefiles whether or not NEO itself uses them. +MISSING=() +for formula in gcc open-mpi netcdf netcdf-fortran fftw; do + brew --prefix "$formula" >/dev/null 2>&1 || MISSING+=("$formula") +done +if [ ${#MISSING[@]} -gt 0 ]; then + echo "Installing missing dependencies: ${MISSING[*]}" + brew install "${MISSING[@]}" +fi + +export GACODE_ROOT="$GACODE_SOURCE" +export GACODE_PLATFORM="${GACODE_PLATFORM:-GFORTRAN_OSX_BREW}" +export FFTW_INC="$(brew --prefix fftw)/include" +export BREW_LIB="$(brew --prefix)/lib" +export PATH="$GACODE_ROOT/shared/bin:$PATH" + +MAKE_INC="$GACODE_ROOT/platform/build/make.inc.$GACODE_PLATFORM" +if [ ! -f "$MAKE_INC" ]; then + echo "error: no platform file $MAKE_INC" >&2 + echo "Available platforms:" >&2 + ls "$GACODE_ROOT/platform/build" | sed 's/^make\.inc\./ /' >&2 + exit 1 +fi + +echo "GACODE_ROOT = $GACODE_ROOT" +echo "GACODE_PLATFORM = $GACODE_PLATFORM" + +# Order matters: the per-code makefiles link shared/*/*.a and f2py/*/*.a as +# EXTRA_LIBS, so both must exist before any suite member is built. +echo "==> shared libraries" +make -C "$GACODE_ROOT/shared" +echo "==> f2py libraries (expro, geo)" +make -C "$GACODE_ROOT/f2py" + +IFS=',' read -r -a CODE_LIST <<< "$CODES" +for code in "${CODE_LIST[@]}"; do + if [ ! -d "$GACODE_ROOT/$code" ]; then + echo "error: no suite member '$code' in $GACODE_ROOT" >&2 + exit 1 + fi + echo "==> $code" + make -C "$GACODE_ROOT/$code" +done + +echo +echo "Build complete. Export:" +echo +echo " export GACODEHOME=$GACODE_ROOT" +echo + +if [ "$RUN_CHECK" -eq 1 ]; then + echo "==> NEO reg18 regression" + # The neo launcher shells out to neo_parse.py, which imports gacodeinput from + # f2py/pygacode. Without it on PYTHONPATH the parse step fails silently and + # NEO then aborts on a missing input.neo.gen -- see install/check_gacode.py. + export PYTHONPATH="$GACODE_ROOT/f2py:$GACODE_ROOT/f2py/pygacode:${PYTHONPATH:-}" + export PATH="$GACODE_ROOT/neo/bin:$PATH" + SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/vaft-gacode-reg18.XXXXXX")" + trap 'rm -rf "$SCRATCH"' EXIT + cp -R "$GACODE_ROOT/neo/tools/input/reg18" "$SCRATCH/reg18" + EXPECTED="$(tr -d '[:space:]' < "$SCRATCH/reg18/out.neo.prec")" + rm -f "$SCRATCH/reg18/out.neo.prec" + ( cd "$SCRATCH" && neo -e reg18 -n 1 >/dev/null ) + ACTUAL="$(tr -d '[:space:]' < "$SCRATCH/reg18/out.neo.prec")" + if [ "$ACTUAL" = "$EXPECTED" ]; then + echo "reg18 PASS: $ACTUAL" + else + echo "reg18 FAIL: got $ACTUAL, expected $EXPECTED" >&2 + exit 1 + fi +fi diff --git a/install/README.md b/install/README.md index faed4876..c26a1e49 100644 --- a/install/README.md +++ b/install/README.md @@ -513,6 +513,29 @@ NTCC dependency modules only after you pass `-AcceptNtccTerms`. Everything it generates stays inside your NUBEAM source tree. See [`external/nubeam/README.md`](../external/nubeam/README.md). +### GACODE + +GACODE has its own entry point, [`external/gacode/macos.sh`](../external/gacode/macos.sh), +which installs the Homebrew dependencies, builds the shared and `f2py` libraries and the +requested suite members, and can run NEO's shipped `reg18` regression case in the same +invocation: + +```bash +bash external/gacode/macos.sh --gacode-root ~/git/gacode --check +export GACODEHOME=~/git/gacode +export GACODE_PLATFORM=GFORTRAN_OSX_BREW +python install/check_gacode.py --source ~/git/gacode +``` + +Two things about it differ from every other code here. It **builds in place**, so +`GACODEHOME` is the checkout rather than a separate prefix; and each suite member carries +its own `bin`, so the executable is `neo/bin/neo`, not `bin/neo`. It also needs +`GACODE_PLATFORM`, which selects `platform/exec/exec.$GACODE_PLATFORM` at run time -- +`vaft.code.gacode` resolves it up front and lists the available tags, because a wrong +value otherwise fails inside a shell script without naming itself. See +[`external/gacode/README.md`](../external/gacode/README.md). macOS/Apple Silicon only for +now. + ### Linux and macOS CHEASE and GPEC are not yet automated — tracked in diff --git a/install/check_gacode.py b/install/check_gacode.py new file mode 100644 index 00000000..77bc16e5 --- /dev/null +++ b/install/check_gacode.py @@ -0,0 +1,309 @@ +"""Verify a GACODE installation, layer by layer. + +GACODE differs from the other external codes VAFT drives in three ways that +each fail as something else, so each gets its own check here: + +* It **builds in place**. There is no installation prefix: the executables land + inside the source tree, so ``$GACODEHOME`` is the checkout, and "source" and + "prefix" are the same path. +* Every suite member has **its own ``bin``** -- ``neo/bin/neo``, not + ``bin/neo`` -- so the shared ``check_executables`` layout does not apply. +* The launcher shells out to ``neo_parse.py``, which imports ``gacodeinput`` + from ``f2py/pygacode``. When that import fails the launcher carries on and + NEO aborts on a missing ``input.neo.gen``, blaming the wrong thing entirely. + + python install/check_gacode.py --source ~/git/gacode +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys +from typing import Optional, Sequence + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _external_code_common import ( # noqa: E402 + FAIL, + PASS, + SKIP, + WARN, + CheckResult, + check_source_checkout, + check_source_revision, + check_toolchain, + emit, +) + +TITLE = "GACODE environment check" +RERUN = "python install/check_gacode.py" +PROJECT = "GACODE" + +#: Suite members VAFT can drive today. TGLF and CGYRO are issue #553. +CODES = ("neo",) + +#: What a GACODE checkout looks like. +SOURCE_MARKERS = ("Makefile", "shared/bin/gacode_setup", "platform/build", "neo/src") + +BUILD_REMEDIATION = ( + "Build GACODE with:\n" + " bash external/gacode/macos.sh --gacode-root --check" +) + + +def _root(prefix: Optional[str]) -> Optional[Path]: + if not prefix: + return None + return Path(prefix).expanduser() + + +def check_suite_executables(prefix: Optional[str]) -> CheckResult: + """Each suite member's launcher and its compiled binary both exist. + + The launcher is a shell script that ships with the source, so it is present + even before a build; only the binary beside it proves the build happened. + Checking just the launcher would pass on an unbuilt checkout. + """ + label = f"{PROJECT} executables" + root = _root(prefix) + if root is None: + return CheckResult(label, FAIL, "no installation root to look in", BUILD_REMEDIATION) + + problems: list[str] = [] + found: list[str] = [] + for code in CODES: + launcher = root / code / "bin" / code + binary = root / code / "src" / code + if not launcher.is_file(): + problems.append(f"missing launcher {launcher.relative_to(root)}") + continue + if not binary.is_file(): + problems.append( + f"{code} is not built: {binary.relative_to(root)} does not exist" + ) + continue + if binary.stat().st_size == 0: + problems.append(f"{binary.name} is empty, which is what a failed link leaves") + continue + found.append(code) + if problems: + return CheckResult(label, FAIL, "; ".join(problems), BUILD_REMEDIATION) + return CheckResult(label, PASS, f"{', '.join(found)} in {root}") + + +def check_platform(prefix: Optional[str]) -> CheckResult: + """A platform tag is set and this installation carries it. + + An unset or wrong ``GACODE_PLATFORM`` fails inside ``neo/bin/neo`` without + naming the variable, so it is worth failing here instead. + """ + label = "GACODE platform" + root = _root(prefix) + platform = os.environ.get("GACODE_PLATFORM") + if root is None: + return CheckResult(label, SKIP, "no installation root") + build = root / "platform" / "build" + known = sorted( + entry.name[len("make.inc."):] + for entry in build.iterdir() + if entry.is_file() and entry.name.startswith("make.inc.") + ) if build.is_dir() else [] + if not platform: + return CheckResult( + label, + FAIL, + "GACODE_PLATFORM is not set", + "Set it to the tag you built with, for example " + "GFORTRAN_OSX_BREW on macOS. It selects platform/exec/exec.$GACODE_PLATFORM, " + "which the launcher execs.", + ) + if known and platform not in known: + return CheckResult( + label, + FAIL, + f"GACODE_PLATFORM={platform} is not one this installation provides", + f"Available: {', '.join(known)}.", + ) + return CheckResult(label, PASS, platform) + + +def check_pygacode(prefix: Optional[str]) -> CheckResult: + """``gacodeinput`` is importable from the tree, for the launcher's parse step.""" + label = "GACODE input parser" + root = _root(prefix) + if root is None: + return CheckResult(label, SKIP, "no installation root") + module = root / "f2py" / "pygacode" / "gacodeinput.py" + if not module.is_file(): + return CheckResult( + label, + FAIL, + f"{module} is missing, so neo_parse.py cannot run", + "The launcher does not stop when its parse step fails; NEO then aborts on " + "a missing input.neo.gen instead. Check out the full GACODE tree.", + ) + return CheckResult(label, PASS, str(module.parent)) + + +def check_vaft_discovery(prefix: Optional[str]) -> CheckResult: + """VAFT resolves the launcher through its own documented mechanism.""" + label = "VAFT executable discovery" + try: + from vaft.code import gacode + except Exception as error: # pragma: no cover - import environment problem + return CheckResult( + label, FAIL, f"vaft.code.gacode could not be imported: {error}", + "Run install/check_vaft_environment.py first.", + ) + + previous = os.environ.get("GACODEHOME") + if prefix: + os.environ["GACODEHOME"] = str(prefix) + try: + resolved = gacode.find_gacode_executable(gacode.GACODEConfig(), "neo") + except Exception as error: + return CheckResult(label, FAIL, str(error), BUILD_REMEDIATION) + finally: + if prefix: + if previous is None: + os.environ.pop("GACODEHOME", None) + else: + os.environ["GACODEHOME"] = previous + + if resolved is None: + return CheckResult( + label, + FAIL, + "GACODEHOME is not configured, so VAFT has nothing to run", + "Set GACODEHOME to the GACODE checkout you built.", + ) + return CheckResult(label, PASS, str(resolved)) + + +def check_regression(prefix: Optional[str], *, skip: bool) -> CheckResult: + """Run NEO's shipped reg18 case and compare its precision scalar. + + This is the only check that proves the build actually computes, rather than + merely linking. + """ + label = "NEO reg18 regression" + if skip: + return CheckResult(label, SKIP, "--skip-smoke") + root = _root(prefix) + if root is None: + return CheckResult(label, SKIP, "no installation root") + case = root / "neo" / "tools" / "input" / "reg18" + if not case.is_dir(): + return CheckResult(label, SKIP, f"{case} is not in this checkout") + + import shutil + import tempfile + + try: + from vaft.code.gacode._input_gacode import read_input_gacode + from vaft.code.gacode.neo import NEOConfig, run_neo_case + except Exception as error: # pragma: no cover + return CheckResult(label, FAIL, f"the VAFT adapter could not be imported: {error}") + + expected = float((case / "out.neo.prec").read_text().split()[0]) + scratch = tempfile.mkdtemp(prefix="vaft-gacode-reg18-") + try: + config = NEOConfig( + home=str(root), + platform=os.environ.get("GACODE_PLATFORM"), + n_species=3, + rotation_model=2, + ) + result = run_neo_case( + read_input_gacode(case / "input.gacode"), Path(scratch) / "reg18", config + ) + actual = result.outputs_native.precision + except Exception as error: + return CheckResult(label, FAIL, str(error), BUILD_REMEDIATION) + finally: + shutil.rmtree(scratch, ignore_errors=True) + + if actual is None: + return CheckResult(label, FAIL, "NEO wrote no out.neo.prec", BUILD_REMEDIATION) + if abs(actual / expected - 1.0) > 1e-6: + return CheckResult( + label, + FAIL, + f"reg18 gave {actual:.8g} against the shipped {expected:.8g}", + "The build links but does not reproduce GACODE's own reference. Check the " + "platform file's compiler flags.", + ) + return CheckResult(label, PASS, f"{actual:.8g} matches the shipped reference") + + +def check_imas_mapping() -> CheckResult: + """State plainly which half of the picture exists. + + A checker reporting only green would suggest NEO results reach IMAS. They do + not yet: the native container is complete, and the audit that decides which + quantities have a defensible IDS home is deliberately still open. + """ + return CheckResult( + "IMAS mapping", + WARN, + "NEO results stop at the native NeoOutputs container; nothing is written to an IDS", + "Expected. The core_profiles/core_transport mapping is phase 5 of issue #550 " + "and is audited by physical definition, not by field name. Read results through " + "vaft.code.gacode.neo.collect_neo_outputs.", + ) + + +def run_checks( + *, + source: Optional[str] = None, + prefix: Optional[str] = None, + skip_smoke: bool = False, +) -> list[CheckResult]: + """Run every GACODE layer, in the order a run depends on them.""" + # GACODE builds in place, so the source tree is the installation root. + if prefix is None: + prefix = source or os.environ.get("GACODEHOME") or os.environ.get("GACODE_ROOT") + if source is None: + source = prefix + + return [ + check_toolchain(required=bool(source)), + check_source_checkout( + source, project=PROJECT, markers=SOURCE_MARKERS, remediation=BUILD_REMEDIATION + ), + check_source_revision(source, project=PROJECT), + check_suite_executables(prefix), + check_platform(prefix), + check_pygacode(prefix), + check_vaft_discovery(prefix), + check_regression(prefix, skip=skip_smoke), + check_imas_mapping(), + ] + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser( + prog="check_gacode", + description="Verify a GACODE installation and the environment it needs.", + ) + parser.add_argument("--source", help="path to your GACODE checkout") + parser.add_argument( + "--prefix", + help="installation root (default: --source, then $GACODEHOME, then $GACODE_ROOT)", + ) + parser.add_argument( + "--skip-smoke", action="store_true", help="do not run the reg18 regression case" + ) + parser.add_argument("--json", action="store_true", dest="as_json", help="emit JSON") + arguments = parser.parse_args(argv) + + results = run_checks( + source=arguments.source, prefix=arguments.prefix, skip_smoke=arguments.skip_smoke + ) + return emit(results, title=TITLE, rerun=RERUN, as_json=arguments.as_json) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/notebooks/initialize_external_fusion_codes.ipynb b/notebooks/initialize_external_fusion_codes.ipynb index f23bfa97..485b1917 100644 --- a/notebooks/initialize_external_fusion_codes.ipynb +++ b/notebooks/initialize_external_fusion_codes.ipynb @@ -33,6 +33,7 @@ "| `EFITHOME` | EFIT root | `bin/efit` | `vaft.code.efit` |\n", "| `TESHOME` | TES root | `bin/rtes` | `vaft.code.tes` |\n", "| `NUBEAMHOME` | NUBEAM root | `bin/nubeam_comp_exec`, `bin/plasma_state_test`, `bin/update_state` | `vaft.code.nubeam` |\n", + "| `GACODEHOME` | GACODE root | `neo/bin/neo` | `vaft.code.gacode` |\n", "\n", "Expected layout:\n", "\n", @@ -42,8 +43,19 @@ "$EFITHOME/bin/efit\n", "$TESHOME/bin/rtes\n", "$NUBEAMHOME/bin/{nubeam_comp_exec,plasma_state_test,update_state}\n", + "$GACODEHOME/neo/bin/neo\n", "```\n", "\n", + "GACODE is the exception to the `bin/` layout above: it is a suite whose members each\n", + "carry their own `bin`, and it builds in place, so `GACODEHOME` is the source checkout\n", + "rather than a separate installation prefix. It also needs `GACODE_PLATFORM`, which\n", + "selects `platform/exec/exec.$GACODE_PLATFORM` at run time and must name the tag the\n", + "tree was built with. VAFT sets GACODE's own `GACODE_ROOT` and `GACODE_PLATFORM` for the\n", + "subprocess from these rather than redefining them, and accepts `GACODE_ROOT` as a\n", + "fallback when `GACODEHOME` is unset, so a tree configured through\n", + "`shared/bin/gacode_setup` keeps working. Build it with `external/gacode/macos.sh` and\n", + "verify it with `python install/check_gacode.py`.\n", + "\n", "`NUBEAMHOME` additionally supplies the two reaction databases NUBEAM cannot run without,\n", "at `$NUBEAMHOME/share/preact` and `$NUBEAMHOME/share/adas`. Both are populated by\n", "`external/nubeam/macos.sh`, and both must stay writable: the table code caches newly\n", @@ -67,6 +79,8 @@ "export EFITHOME=/opt/efit\n", "export TESHOME=/opt/tes\n", "export NUBEAMHOME=/opt/nubeam\n", + "export GACODEHOME=/opt/gacode\n", + "export GACODE_PLATFORM=GFORTRAN_OSX_BREW\n", "export VAFT_FILEDB_DIR=/data/VEST/FileDB\n", "```\n", "\n", @@ -75,12 +89,9 @@ "On Windows, set them as *user* environment variables. The registered \"Python (vaft)\" kernel starts the environment's `python.exe` directly rather than through Conda activation, so an `activate.d` script never reaches a notebook, while a user variable reaches every newly started process:\n", "\n", "```powershell\n", - "[Environment]::SetEnvironmentVariable('GPECHOME', \"$env:LOCALAPPDATA\\v", - "aft\\external\\gpec\", 'User')\n", - "[Environment]::SetEnvironmentVariable('CHEASEHOME', \"$env:LOCALAPPDATA\\v", - "aft\\external\\chease\", 'User')\n", - "$env:GPECHOME = \"$env:LOCALAPPDATA\\v", - "aft\\external\\gpec\"\n", + "[Environment]::SetEnvironmentVariable('GPECHOME', \"$env:LOCALAPPDATA\\vaft\\external\\gpec\", 'User')\n", + "[Environment]::SetEnvironmentVariable('CHEASEHOME', \"$env:LOCALAPPDATA\\vaft\\external\\chease\", 'User')\n", + "$env:GPECHOME = \"$env:LOCALAPPDATA\\vaft\\external\\gpec\"\n", "```\n", "\n", "Open a new terminal, or restart JupyterLab, for it to take effect elsewhere. The executables under `bin/` are named `dcon.exe`, `chease.exe` and so on there; VAFT resolves the documented POSIX name to the native build beside it, so the layouts below are written the same way on every platform. `install/README.md` covers building the codes natively on Windows.\n", @@ -145,6 +156,8 @@ " \"EFITHOME\": (\"bin/efit\",),\n", " \"TESHOME\": (\"bin/rtes\",),\n", " \"NUBEAMHOME\": (\"bin/nubeam_comp_exec\", \"bin/plasma_state_test\", \"bin/update_state\"),\n", + " # GACODE gives every suite member its own bin, so this is not a bin/ path.\n", + " \"GACODEHOME\": (\"neo/bin/neo\",),\n", "}\n", "\n", "for variable, relative_executables in external_codes.items():\n", diff --git a/test/data/gacode/neo_reg18/input.gacode b/test/data/gacode/neo_reg18/input.gacode new file mode 100644 index 00000000..f4cca465 --- /dev/null +++ b/test/data/gacode/neo_reg18/input.gacode @@ -0,0 +1,2063 @@ +# *original : Mon 26 Apr 2021 04:18:36 PM PDT +# *statefile : iterdb141459.03890 +# *gfile : g141459.03890 05/12/98 65 65 +# *cerfile : cer141459.03890 +# *vgen : null +# *tgyro : null +# +# nexp +51 +# nion +2 +# shot +141459 +# time +3890 +# name +D C +# type +[therm] [therm] +# masse + 5.4488741E-04 +# mass + 2.0000000E+00 1.2000000E+01 +# ze +-1.0000000E+00 +# z + 1.0000000E+00 6.0000000E+00 +# torfluxa | Wb/radian + 5.6625370E-01 +# rcentr | m + 1.6955000E+00 +# bcentr | T + 1.8316507E+00 +# current | MA +-1.2579084E+00 +# rho | - + 1 0.0000000E+00 + 2 2.0000000E-02 + 3 4.0000000E-02 + 4 6.0000000E-02 + 5 8.0000000E-02 + 6 1.0000000E-01 + 7 1.2000000E-01 + 8 1.4000000E-01 + 9 1.6000000E-01 + 10 1.8000000E-01 + 11 2.0000000E-01 + 12 2.2000000E-01 + 13 2.4000000E-01 + 14 2.6000000E-01 + 15 2.8000000E-01 + 16 3.0000000E-01 + 17 3.2000000E-01 + 18 3.4000000E-01 + 19 3.6000000E-01 + 20 3.8000000E-01 + 21 4.0000000E-01 + 22 4.2000000E-01 + 23 4.4000000E-01 + 24 4.6000000E-01 + 25 4.8000000E-01 + 26 5.0000000E-01 + 27 5.2000000E-01 + 28 5.4000000E-01 + 29 5.6000000E-01 + 30 5.8000000E-01 + 31 6.0000000E-01 + 32 6.2000000E-01 + 33 6.4000000E-01 + 34 6.6000000E-01 + 35 6.8000000E-01 + 36 7.0000000E-01 + 37 7.2000000E-01 + 38 7.4000000E-01 + 39 7.6000000E-01 + 40 7.8000000E-01 + 41 8.0000000E-01 + 42 8.2000000E-01 + 43 8.4000000E-01 + 44 8.6000000E-01 + 45 8.8000000E-01 + 46 9.0000000E-01 + 47 9.2000000E-01 + 48 9.4000000E-01 + 49 9.6000000E-01 + 50 9.8000000E-01 + 51 1.0000000E+00 +# rmin | m + 1 0.0000000E+00 + 2 1.3998616E-02 + 3 2.7999723E-02 + 4 4.2003323E-02 + 5 5.6000761E-02 + 6 6.9994862E-02 + 7 8.3982453E-02 + 8 9.7961073E-02 + 9 1.1192813E-01 + 10 1.2588358E-01 + 11 1.3982023E-01 + 12 1.5373602E-01 + 13 1.6762546E-01 + 14 1.8148596E-01 + 15 1.9530945E-01 + 16 2.0909053E-01 + 17 2.2282359E-01 + 18 2.3650113E-01 + 19 2.5011639E-01 + 20 2.6366033E-01 + 21 2.7712608E-01 + 22 2.9050591E-01 + 23 3.0379043E-01 + 24 3.1697007E-01 + 25 3.3004050E-01 + 26 3.4299068E-01 + 27 3.5581081E-01 + 28 3.6849306E-01 + 29 3.8102956E-01 + 30 3.9341192E-01 + 31 4.0563154E-01 + 32 4.1768229E-01 + 33 4.2955503E-01 + 34 4.4123856E-01 + 35 4.5273589E-01 + 36 4.6402777E-01 + 37 4.7511372E-01 + 38 4.8598308E-01 + 39 4.9663496E-01 + 40 5.0705700E-01 + 41 5.1724548E-01 + 42 5.2718873E-01 + 43 5.3688202E-01 + 44 5.4631259E-01 + 45 5.5546473E-01 + 46 5.6434221E-01 + 47 5.7290982E-01 + 48 5.8114633E-01 + 49 5.8899981E-01 + 50 5.9636631E-01 + 51 6.0301985E-01 +# polflux | Wb/radian + 1 -0.0000000E+00 + 2 -4.6849843E-04 + 3 -1.3269822E-03 + 4 -2.9181214E-03 + 5 -5.1786847E-03 + 6 -8.0582373E-03 + 7 -1.1544848E-02 + 8 -1.5619120E-02 + 9 -2.0258892E-02 + 10 -2.5438831E-02 + 11 -3.1130757E-02 + 12 -3.7304302E-02 + 13 -4.3926851E-02 + 14 -5.0963852E-02 + 15 -5.8379437E-02 + 16 -6.6136575E-02 + 17 -7.4197468E-02 + 18 -8.2524001E-02 + 19 -9.1078197E-02 + 20 -9.9822300E-02 + 21 -1.0871947E-01 + 22 -1.1773399E-01 + 23 -1.2683131E-01 + 24 -1.3597874E-01 + 25 -1.4514532E-01 + 26 -1.5430201E-01 + 27 -1.6342208E-01 + 28 -1.7248080E-01 + 29 -1.8145573E-01 + 30 -1.9032673E-01 + 31 -1.9907569E-01 + 32 -2.0768678E-01 + 33 -2.1614608E-01 + 34 -2.2444166E-01 + 35 -2.3256340E-01 + 36 -2.4050278E-01 + 37 -2.4825273E-01 + 38 -2.5580750E-01 + 39 -2.6316251E-01 + 40 -2.7031414E-01 + 41 -2.7725951E-01 + 42 -2.8399593E-01 + 43 -2.9052064E-01 + 44 -2.9683046E-01 + 45 -3.0292128E-01 + 46 -3.0878716E-01 + 47 -3.1441879E-01 + 48 -3.1980165E-01 + 49 -3.2491261E-01 + 50 -3.2969966E-01 + 51 -3.3402027E-01 +# q | - + 1 -6.9481975E-01 + 2 -6.9562842E-01 + 3 -6.9718441E-01 + 4 -7.0032212E-01 + 5 -7.0504561E-01 + 6 -7.1092361E-01 + 7 -7.1829953E-01 + 8 -7.2713764E-01 + 9 -7.3742790E-01 + 10 -7.4933509E-01 + 11 -7.6288899E-01 + 12 -7.7814694E-01 + 13 -7.9524731E-01 + 14 -8.1425853E-01 + 15 -8.3528625E-01 + 16 -8.5845746E-01 + 17 -8.8389430E-01 + 18 -9.1172060E-01 + 19 -9.4206897E-01 + 20 -9.7510744E-01 + 21 -1.0109670E+00 + 22 -1.0498243E+00 + 23 -1.0918478E+00 + 24 -1.1371807E+00 + 25 -1.1860877E+00 + 26 -1.2386917E+00 + 27 -1.2952279E+00 + 28 -1.3559250E+00 + 29 -1.4209813E+00 + 30 -1.4906614E+00 + 31 -1.5652110E+00 + 32 -1.6448765E+00 + 33 -1.7299702E+00 + 34 -1.8207665E+00 + 35 -1.9176017E+00 + 36 -2.0208219E+00 + 37 -2.1308482E+00 + 38 -2.2480858E+00 + 39 -2.3730533E+00 + 40 -2.5062778E+00 + 41 -2.6485516E+00 + 42 -2.8008372E+00 + 43 -2.9644712E+00 + 44 -3.1410888E+00 + 45 -3.3332194E+00 + 46 -3.5445759E+00 + 47 -3.7813932E+00 + 48 -4.0535230E+00 + 49 -4.3809876E+00 + 50 -4.8288941E+00 + 51 -5.6752729E+00 +# w0 | rad/s + 1 -6.3284400E+04 + 2 -6.2682100E+04 + 3 -6.1446800E+04 + 4 -5.9809100E+04 + 5 -5.8157100E+04 + 6 -5.6742000E+04 + 7 -5.5534400E+04 + 8 -5.4482000E+04 + 9 -5.3557700E+04 + 10 -5.2735200E+04 + 11 -5.1988000E+04 + 12 -5.1289600E+04 + 13 -5.0616600E+04 + 14 -4.9940000E+04 + 15 -4.9236400E+04 + 16 -4.8478600E+04 + 17 -4.7642300E+04 + 18 -4.6720000E+04 + 19 -4.5715400E+04 + 20 -4.4634000E+04 + 21 -4.3480300E+04 + 22 -4.2259500E+04 + 23 -4.0976500E+04 + 24 -3.9636300E+04 + 25 -3.8243800E+04 + 26 -3.6804200E+04 + 27 -3.5322600E+04 + 28 -3.3799200E+04 + 29 -3.2217600E+04 + 30 -3.0576400E+04 + 31 -2.8882200E+04 + 32 -2.7141400E+04 + 33 -2.5348000E+04 + 34 -2.3488200E+04 + 35 -2.1565700E+04 + 36 -1.9585200E+04 + 37 -1.7551000E+04 + 38 -1.5469000E+04 + 39 -1.3365200E+04 + 40 -1.1255200E+04 + 41 -9.1419000E+03 + 42 -7.0267800E+03 + 43 -4.9065500E+03 + 44 -2.7733900E+03 + 45 9.2437900E+02 + 46 9.9112900E+03 + 47 1.9299000E+04 + 48 2.3661900E+04 + 49 2.3450000E+04 + 50 2.2169500E+04 + 51 3.3242600E+04 +# rmaj | m + 1 1.7561671E+00 + 2 1.7561495E+00 + 3 1.7560972E+00 + 4 1.7560104E+00 + 5 1.7558862E+00 + 6 1.7557256E+00 + 7 1.7555296E+00 + 8 1.7552947E+00 + 9 1.7550211E+00 + 10 1.7547084E+00 + 11 1.7543538E+00 + 12 1.7539569E+00 + 13 1.7535160E+00 + 14 1.7530306E+00 + 15 1.7524971E+00 + 16 1.7519162E+00 + 17 1.7512828E+00 + 18 1.7505969E+00 + 19 1.7498585E+00 + 20 1.7490650E+00 + 21 1.7482115E+00 + 22 1.7472992E+00 + 23 1.7463242E+00 + 24 1.7452889E+00 + 25 1.7441904E+00 + 26 1.7430265E+00 + 27 1.7417973E+00 + 28 1.7405030E+00 + 29 1.7391422E+00 + 30 1.7377174E+00 + 31 1.7362263E+00 + 32 1.7346746E+00 + 33 1.7330604E+00 + 34 1.7313868E+00 + 35 1.7296589E+00 + 36 1.7278764E+00 + 37 1.7260461E+00 + 38 1.7241722E+00 + 39 1.7222610E+00 + 40 1.7203152E+00 + 41 1.7183434E+00 + 42 1.7163492E+00 + 43 1.7143432E+00 + 44 1.7123268E+00 + 45 1.7103152E+00 + 46 1.7083291E+00 + 47 1.7063642E+00 + 48 1.7044352E+00 + 49 1.7025623E+00 + 50 1.7007682E+00 + 51 1.6991125E+00 +# zmag | m + 1 2.5560626E-02 + 2 2.5542018E-02 + 3 2.5523455E-02 + 4 2.5504937E-02 + 5 2.5482169E-02 + 6 2.5451056E-02 + 7 2.5412383E-02 + 8 2.5364732E-02 + 9 2.5309046E-02 + 10 2.5242367E-02 + 11 2.5167607E-02 + 12 2.5080878E-02 + 13 2.4981278E-02 + 14 2.4869233E-02 + 15 2.4740005E-02 + 16 2.4593903E-02 + 17 2.4430001E-02 + 18 2.4243233E-02 + 19 2.4031718E-02 + 20 2.3799724E-02 + 21 2.3531050E-02 + 22 2.3226652E-02 + 23 2.2891590E-02 + 24 2.2504898E-02 + 25 2.2071076E-02 + 26 2.1578567E-02 + 27 2.1023908E-02 + 28 2.0397225E-02 + 29 1.9683177E-02 + 30 1.8869641E-02 + 31 1.7951928E-02 + 32 1.6906565E-02 + 33 1.5721220E-02 + 34 1.4372625E-02 + 35 1.2823736E-02 + 36 1.1063972E-02 + 37 9.0456175E-03 + 38 6.7323997E-03 + 39 4.0829929E-03 + 40 1.0392184E-03 + 41 -2.4617900E-03 + 42 -6.5125225E-03 + 43 -1.1194468E-02 + 44 -1.6651811E-02 + 45 -2.3026737E-02 + 46 -3.0506919E-02 + 47 -3.9411284E-02 + 48 -5.0245754E-02 + 49 -6.3824030E-02 + 50 -8.2060223E-02 + 51 -1.1083981E-01 +# kappa | - + 1 1.2515467E+00 + 2 1.2519586E+00 + 3 1.2523696E+00 + 4 1.2527795E+00 + 5 1.2535000E+00 + 6 1.2542792E+00 + 7 1.2553094E+00 + 8 1.2565644E+00 + 9 1.2580246E+00 + 10 1.2596578E+00 + 11 1.2615483E+00 + 12 1.2636446E+00 + 13 1.2659686E+00 + 14 1.2685156E+00 + 15 1.2713174E+00 + 16 1.2743736E+00 + 17 1.2776746E+00 + 18 1.2812417E+00 + 19 1.2850902E+00 + 20 1.2892300E+00 + 21 1.2936602E+00 + 22 1.2984136E+00 + 23 1.3034824E+00 + 24 1.3089002E+00 + 25 1.3146378E+00 + 26 1.3207464E+00 + 27 1.3272349E+00 + 28 1.3341040E+00 + 29 1.3413814E+00 + 30 1.3490958E+00 + 31 1.3572508E+00 + 32 1.3658617E+00 + 33 1.3749480E+00 + 34 1.3845779E+00 + 35 1.3947285E+00 + 36 1.4054421E+00 + 37 1.4167829E+00 + 38 1.4287602E+00 + 39 1.4414433E+00 + 40 1.4548674E+00 + 41 1.4691129E+00 + 42 1.4842977E+00 + 43 1.5004427E+00 + 44 1.5177528E+00 + 45 1.5364441E+00 + 46 1.5567090E+00 + 47 1.5790622E+00 + 48 1.6040586E+00 + 49 1.6328207E+00 + 50 1.6683033E+00 + 51 1.7202724E+00 +# delta | - + 1 0.0000000E+00 + 2 1.9633380E-03 + 3 4.0079798E-03 + 4 6.1339152E-03 + 5 8.1908788E-03 + 6 1.0250235E-02 + 7 1.2382541E-02 + 8 1.4518591E-02 + 9 1.6700722E-02 + 10 1.8960158E-02 + 11 2.1268487E-02 + 12 2.3620273E-02 + 13 2.6041060E-02 + 14 2.8564350E-02 + 15 3.1169489E-02 + 16 3.3884228E-02 + 17 3.6676541E-02 + 18 3.9620430E-02 + 19 4.2656458E-02 + 20 4.5870264E-02 + 21 4.9255138E-02 + 22 5.2768709E-02 + 23 5.6471885E-02 + 24 6.0418027E-02 + 25 6.4557737E-02 + 26 6.8974888E-02 + 27 7.3610849E-02 + 28 7.8557823E-02 + 29 8.3798738E-02 + 30 8.9364016E-02 + 31 9.5311319E-02 + 32 1.0161710E-01 + 33 1.0835794E-01 + 34 1.1553085E-01 + 35 1.2319382E-01 + 36 1.3136204E-01 + 37 1.4008578E-01 + 38 1.4937712E-01 + 39 1.5924258E-01 + 40 1.6980512E-01 + 41 1.8108271E-01 + 42 1.9312250E-01 + 43 2.0595109E-01 + 44 2.1974810E-01 + 45 2.3459196E-01 + 46 2.5068565E-01 + 47 2.6834943E-01 + 48 2.8803377E-01 + 49 3.1045868E-01 + 50 3.3744130E-01 + 51 3.7606871E-01 +# zeta | - + 1 -0.0000000E+00 + 2 4.8731841E-04 + 3 5.1751457E-04 + 4 9.0588465E-05 + 5 -2.1215330E-04 + 6 -3.5515127E-04 + 7 -5.8135780E-04 + 8 -8.3806685E-04 + 9 -1.1038601E-03 + 10 -1.4235743E-03 + 11 -1.7934664E-03 + 12 -2.1865604E-03 + 13 -2.6043571E-03 + 14 -3.0833555E-03 + 15 -3.6056376E-03 + 16 -4.1728117E-03 + 17 -4.7762953E-03 + 18 -5.4232766E-03 + 19 -6.1294385E-03 + 20 -6.8750577E-03 + 21 -7.6607740E-03 + 22 -8.5243227E-03 + 23 -9.4212891E-03 + 24 -1.0371568E-02 + 25 -1.1363085E-02 + 26 -1.2420822E-02 + 27 -1.3523414E-02 + 28 -1.4655691E-02 + 29 -1.5843107E-02 + 30 -1.7098671E-02 + 31 -1.8399574E-02 + 32 -1.9754631E-02 + 33 -2.1152175E-02 + 34 -2.2625259E-02 + 35 -2.4193608E-02 + 36 -2.5781780E-02 + 37 -2.7487341E-02 + 38 -2.9252359E-02 + 39 -3.1161316E-02 + 40 -3.3165811E-02 + 41 -3.5321329E-02 + 42 -3.7673657E-02 + 43 -4.0167315E-02 + 44 -4.2917806E-02 + 45 -4.5992500E-02 + 46 -4.9528642E-02 + 47 -5.3730895E-02 + 48 -5.8817599E-02 + 49 -6.5224803E-02 + 50 -7.4507228E-02 + 51 -9.2352873E-02 +# shape_cos0 | - + 1 -2.6376759E-02 + 2 -2.6385091E-02 + 3 -2.6409791E-02 + 4 -2.6450860E-02 + 5 -2.6502093E-02 + 6 -2.6583561E-02 + 7 -2.6679772E-02 + 8 -2.6788441E-02 + 9 -2.6906505E-02 + 10 -2.7059395E-02 + 11 -2.7215944E-02 + 12 -2.7399684E-02 + 13 -2.7608992E-02 + 14 -2.7827477E-02 + 15 -2.8085326E-02 + 16 -2.8348405E-02 + 17 -2.8627935E-02 + 18 -2.8950692E-02 + 19 -2.9295446E-02 + 20 -2.9663095E-02 + 21 -3.0056615E-02 + 22 -3.0479170E-02 + 23 -3.0936955E-02 + 24 -3.1415888E-02 + 25 -3.1929944E-02 + 26 -3.2492831E-02 + 27 -3.3092668E-02 + 28 -3.3749248E-02 + 29 -3.4456579E-02 + 30 -3.5208111E-02 + 31 -3.6014965E-02 + 32 -3.6917413E-02 + 33 -3.7876008E-02 + 34 -3.8943220E-02 + 35 -4.0118727E-02 + 36 -4.1439790E-02 + 37 -4.2885451E-02 + 38 -4.4496066E-02 + 39 -4.6326453E-02 + 40 -4.8369682E-02 + 41 -5.0747074E-02 + 42 -5.3413627E-02 + 43 -5.6520876E-02 + 44 -6.0133540E-02 + 45 -6.4396741E-02 + 46 -6.9454303E-02 + 47 -7.5589732E-02 + 48 -8.3137963E-02 + 49 -9.2807431E-02 + 50 -1.0608132E-01 + 51 -1.2820867E-01 +# shape_cos1 | - + 1 0.0000000E+00 + 2 -3.8871461E-04 + 3 -7.7607054E-04 + 4 -1.1620678E-03 + 5 -1.5323931E-03 + 6 -1.9381629E-03 + 7 -2.3473186E-03 + 8 -2.7731740E-03 + 9 -3.1815614E-03 + 10 -3.6494803E-03 + 11 -4.0898480E-03 + 12 -4.5556616E-03 + 13 -5.0475350E-03 + 14 -5.5462142E-03 + 15 -6.0902996E-03 + 16 -6.6591828E-03 + 17 -7.2567614E-03 + 18 -7.8679954E-03 + 19 -8.5378875E-03 + 20 -9.2325490E-03 + 21 -9.9743338E-03 + 22 -1.0812383E-02 + 23 -1.1645985E-02 + 24 -1.2562488E-02 + 25 -1.3574688E-02 + 26 -1.4661212E-02 + 27 -1.5824251E-02 + 28 -1.7085234E-02 + 29 -1.8475831E-02 + 30 -2.0010485E-02 + 31 -2.1679375E-02 + 32 -2.3531292E-02 + 33 -2.5539673E-02 + 34 -2.7753252E-02 + 35 -3.0248194E-02 + 36 -3.2993753E-02 + 37 -3.6101849E-02 + 38 -3.9547084E-02 + 39 -4.3417830E-02 + 40 -4.7729524E-02 + 41 -5.2621769E-02 + 42 -5.8148097E-02 + 43 -6.4432415E-02 + 44 -7.1614682E-02 + 45 -7.9863977E-02 + 46 -8.9337415E-02 + 47 -1.0046472E-01 + 48 -1.1385804E-01 + 49 -1.3066638E-01 + 50 -1.5349464E-01 + 51 -1.9091849E-01 +# shape_cos2 | - + 1 0.0000000E+00 + 2 -5.2650196E-06 + 3 5.7637158E-06 + 4 3.3086206E-05 + 5 7.0900193E-05 + 6 1.1183257E-04 + 7 1.6872149E-04 + 8 2.5242087E-04 + 9 3.0714835E-04 + 10 4.0380576E-04 + 11 5.0428272E-04 + 12 6.3379949E-04 + 13 7.8142029E-04 + 14 8.6907299E-04 + 15 1.0577280E-03 + 16 1.2138592E-03 + 17 1.4066733E-03 + 18 1.6531933E-03 + 19 1.8854148E-03 + 20 2.1477696E-03 + 21 2.4228543E-03 + 22 2.7515729E-03 + 23 3.0650201E-03 + 24 3.4403581E-03 + 25 3.8353618E-03 + 26 4.2645162E-03 + 27 4.7560565E-03 + 28 5.2667061E-03 + 29 5.8586092E-03 + 30 6.4480083E-03 + 31 7.0926799E-03 + 32 7.8397687E-03 + 33 8.6010624E-03 + 34 9.4968834E-03 + 35 1.0403342E-02 + 36 1.1495755E-02 + 37 1.2611514E-02 + 38 1.3880071E-02 + 39 1.5278872E-02 + 40 1.6827668E-02 + 41 1.8626560E-02 + 42 2.0589902E-02 + 43 2.2845011E-02 + 44 2.5446251E-02 + 45 2.8485361E-02 + 46 3.2116020E-02 + 47 3.6568553E-02 + 48 4.1995302E-02 + 49 4.8908796E-02 + 50 5.8426840E-02 + 51 7.5111496E-02 +# shape_cos3 | - + 1 0.0000000E+00 + 2 -5.5721994E-06 + 3 -1.3344390E-06 + 4 1.2713281E-05 + 5 8.5326911E-06 + 6 2.9423383E-06 + 7 -1.1220917E-06 + 8 1.7941228E-05 + 9 4.0442750E-05 + 10 5.8421259E-05 + 11 6.6258728E-05 + 12 8.6636093E-05 + 13 1.1416676E-04 + 14 1.3852550E-04 + 15 1.8162956E-04 + 16 2.2885410E-04 + 17 2.6838098E-04 + 18 3.5802440E-04 + 19 4.4380274E-04 + 20 4.8014764E-04 + 21 6.0356268E-04 + 22 7.0786571E-04 + 23 8.0529405E-04 + 24 9.7680109E-04 + 25 1.1145034E-03 + 26 1.2950418E-03 + 27 1.4904131E-03 + 28 1.7004743E-03 + 29 1.9501699E-03 + 30 2.2374677E-03 + 31 2.5267418E-03 + 32 2.8468615E-03 + 33 3.2077405E-03 + 34 3.6049965E-03 + 35 4.0687295E-03 + 36 4.5581762E-03 + 37 5.0850701E-03 + 38 5.7059095E-03 + 39 6.3574584E-03 + 40 7.1125554E-03 + 41 7.8920174E-03 + 42 8.7975269E-03 + 43 9.7468325E-03 + 44 1.0841590E-02 + 45 1.2036625E-02 + 46 1.3352132E-02 + 47 1.4843120E-02 + 48 1.6698473E-02 + 49 1.9022191E-02 + 50 2.2715192E-02 + 51 3.0537971E-02 +# shape_sin3 | - + 1 0.0000000E+00 + 2 -2.2809206E-05 + 3 -2.3153849E-05 + 4 -1.0339279E-06 + 5 -1.5420822E-05 + 6 -1.4080892E-05 + 7 4.0372997E-05 + 8 7.1387871E-05 + 9 1.1314548E-04 + 10 1.5870728E-04 + 11 2.0024357E-04 + 12 2.7919789E-04 + 13 3.7197247E-04 + 14 4.7970512E-04 + 15 5.8970571E-04 + 16 7.3172301E-04 + 17 8.8768861E-04 + 18 1.0350145E-03 + 19 1.2663154E-03 + 20 1.4947100E-03 + 21 1.6958828E-03 + 22 1.9651236E-03 + 23 2.2271664E-03 + 24 2.4981634E-03 + 25 2.8072085E-03 + 26 3.0796275E-03 + 27 3.4011692E-03 + 28 3.6984501E-03 + 29 3.9935496E-03 + 30 4.2956101E-03 + 31 4.5334184E-03 + 32 4.7887212E-03 + 33 4.9724091E-03 + 34 5.1209722E-03 + 35 5.2013275E-03 + 36 5.2025147E-03 + 37 5.1099869E-03 + 38 4.9439733E-03 + 39 4.7182583E-03 + 40 4.3318920E-03 + 41 3.8072783E-03 + 42 3.1323089E-03 + 43 2.3184715E-03 + 44 1.2089830E-03 + 45 -1.5176539E-04 + 46 -1.7957337E-03 + 47 -3.9221491E-03 + 48 -6.6789748E-03 + 49 -1.0262922E-02 + 50 -1.5269428E-02 + 51 -2.4632268E-02 +# ne | 10^19/m^3 + 1 6.6101000E+00 + 2 6.6074000E+00 + 3 6.5994000E+00 + 4 6.5863000E+00 + 5 6.5683000E+00 + 6 6.5455000E+00 + 7 6.5183000E+00 + 8 6.4867000E+00 + 9 6.4509000E+00 + 10 6.4112000E+00 + 11 6.3678000E+00 + 12 6.3208000E+00 + 13 6.2704000E+00 + 14 6.2169000E+00 + 15 6.1603000E+00 + 16 6.1010000E+00 + 17 6.0391000E+00 + 18 5.9747000E+00 + 19 5.9082000E+00 + 20 5.8396000E+00 + 21 5.7692000E+00 + 22 5.6971000E+00 + 23 5.6236000E+00 + 24 5.5489000E+00 + 25 5.4730000E+00 + 26 5.3964000E+00 + 27 5.3190000E+00 + 28 5.2412000E+00 + 29 5.1630000E+00 + 30 5.0848000E+00 + 31 5.0067000E+00 + 32 4.9288000E+00 + 33 4.8514000E+00 + 34 4.7747000E+00 + 35 4.6989000E+00 + 36 4.6241000E+00 + 37 4.5506000E+00 + 38 4.4785000E+00 + 39 4.4080000E+00 + 40 4.3394000E+00 + 41 4.2728000E+00 + 42 4.2083000E+00 + 43 4.1463000E+00 + 44 4.0869000E+00 + 45 4.0303000E+00 + 46 3.9767000E+00 + 47 3.9232000E+00 + 48 3.7825000E+00 + 49 3.3744000E+00 + 50 2.5139000E+00 + 51 1.0160000E+00 +# ni | 10^19/m^3 + 1 5.3635000E+00 1.7599000E-01 + 2 5.3607000E+00 1.7595000E-01 + 3 5.3530000E+00 1.7586000E-01 + 4 5.3418000E+00 1.7571000E-01 + 5 5.3269000E+00 1.7552000E-01 + 6 5.3084000E+00 1.7528000E-01 + 7 5.2866000E+00 1.7502000E-01 + 8 5.2615000E+00 1.7472000E-01 + 9 5.2332000E+00 1.7440000E-01 + 10 5.2017000E+00 1.7406000E-01 + 11 5.1666000E+00 1.7371000E-01 + 12 5.1281000E+00 1.7337000E-01 + 13 5.0860000E+00 1.7302000E-01 + 14 5.0402000E+00 1.7269000E-01 + 15 4.9909000E+00 1.7237000E-01 + 16 4.9383000E+00 1.7207000E-01 + 17 4.8824000E+00 1.7180000E-01 + 18 4.8238000E+00 1.7157000E-01 + 19 4.7628000E+00 1.7138000E-01 + 20 4.6997000E+00 1.7123000E-01 + 21 4.6348000E+00 1.7114000E-01 + 22 4.5683000E+00 1.7111000E-01 + 23 4.5003000E+00 1.7115000E-01 + 24 4.4308000E+00 1.7126000E-01 + 25 4.3598000E+00 1.7145000E-01 + 26 4.2874000E+00 1.7173000E-01 + 27 4.2136000E+00 1.7210000E-01 + 28 4.1384000E+00 1.7257000E-01 + 29 4.0621000E+00 1.7314000E-01 + 30 3.9848000E+00 1.7383000E-01 + 31 3.9066000E+00 1.7463000E-01 + 32 3.8278000E+00 1.7555000E-01 + 33 3.7487000E+00 1.7659000E-01 + 34 3.6702000E+00 1.7761000E-01 + 35 3.5937000E+00 1.7843000E-01 + 36 3.5204000E+00 1.7886000E-01 + 37 3.4516000E+00 1.7872000E-01 + 38 3.3886000E+00 1.7781000E-01 + 39 3.3326000E+00 1.7596000E-01 + 40 3.2851000E+00 1.7297000E-01 + 41 3.2471000E+00 1.6867000E-01 + 42 3.2201000E+00 1.6286000E-01 + 43 3.2053000E+00 1.5536000E-01 + 44 3.2041000E+00 1.4599000E-01 + 45 3.2177000E+00 1.3456000E-01 + 46 3.2475000E+00 1.2089000E-01 + 47 3.2917000E+00 1.0478000E-01 + 48 3.2642000E+00 8.6053000E-02 + 49 2.9860000E+00 6.4525000E-02 + 50 2.2710000E+00 4.0387000E-02 + 51 8.6880000E-01 3.6459000E-02 +# te | keV + 1 4.4018000E+00 + 2 4.3979000E+00 + 3 4.3863000E+00 + 4 4.3673000E+00 + 5 4.3412000E+00 + 6 4.3082000E+00 + 7 4.2687000E+00 + 8 4.2230000E+00 + 9 4.1713000E+00 + 10 4.1140000E+00 + 11 4.0512000E+00 + 12 3.9833000E+00 + 13 3.9106000E+00 + 14 3.8334000E+00 + 15 3.7519000E+00 + 16 3.6664000E+00 + 17 3.5773000E+00 + 18 3.4847000E+00 + 19 3.3891000E+00 + 20 3.2906000E+00 + 21 3.1896000E+00 + 22 3.0863000E+00 + 23 2.9811000E+00 + 24 2.8742000E+00 + 25 2.7659000E+00 + 26 2.6564000E+00 + 27 2.5462000E+00 + 28 2.4354000E+00 + 29 2.3243000E+00 + 30 2.2133000E+00 + 31 2.1026000E+00 + 32 1.9925000E+00 + 33 1.8833000E+00 + 34 1.7753000E+00 + 35 1.6687000E+00 + 36 1.5638000E+00 + 37 1.4610000E+00 + 38 1.3605000E+00 + 39 1.2627000E+00 + 40 1.1676000E+00 + 41 1.0758000E+00 + 42 9.8743000E-01 + 43 9.0279000E-01 + 44 8.2218000E-01 + 45 7.4589000E-01 + 46 6.7419000E-01 + 47 6.0615000E-01 + 48 5.2880000E-01 + 49 4.2245000E-01 + 50 2.6731000E-01 + 51 4.4000000E-02 +# ti | keV + 1 3.2708000E+00 3.2708000E+00 + 2 3.2661000E+00 3.2661000E+00 + 3 3.2525000E+00 3.2525000E+00 + 4 3.2306000E+00 3.2306000E+00 + 5 3.2009000E+00 3.2009000E+00 + 6 3.1641000E+00 3.1641000E+00 + 7 3.1206000E+00 3.1206000E+00 + 8 3.0712000E+00 3.0712000E+00 + 9 3.0164000E+00 3.0164000E+00 + 10 2.9567000E+00 2.9567000E+00 + 11 2.8928000E+00 2.8928000E+00 + 12 2.8252000E+00 2.8252000E+00 + 13 2.7546000E+00 2.7546000E+00 + 14 2.6815000E+00 2.6815000E+00 + 15 2.6065000E+00 2.6065000E+00 + 16 2.5301000E+00 2.5301000E+00 + 17 2.4531000E+00 2.4531000E+00 + 18 2.3759000E+00 2.3759000E+00 + 19 2.2992000E+00 2.2992000E+00 + 20 2.2234000E+00 2.2234000E+00 + 21 2.1493000E+00 2.1493000E+00 + 22 2.0774000E+00 2.0774000E+00 + 23 2.0083000E+00 2.0083000E+00 + 24 1.9426000E+00 1.9426000E+00 + 25 1.8808000E+00 1.8808000E+00 + 26 1.8236000E+00 1.8236000E+00 + 27 1.7715000E+00 1.7715000E+00 + 28 1.7251000E+00 1.7251000E+00 + 29 1.6841000E+00 1.6841000E+00 + 30 1.6474000E+00 1.6474000E+00 + 31 1.6139000E+00 1.6139000E+00 + 32 1.5824000E+00 1.5824000E+00 + 33 1.5517000E+00 1.5517000E+00 + 34 1.5206000E+00 1.5206000E+00 + 35 1.4880000E+00 1.4880000E+00 + 36 1.4527000E+00 1.4527000E+00 + 37 1.4136000E+00 1.4136000E+00 + 38 1.3694000E+00 1.3694000E+00 + 39 1.3196000E+00 1.3196000E+00 + 40 1.2645000E+00 1.2645000E+00 + 41 1.2045000E+00 1.2045000E+00 + 42 1.1401000E+00 1.1401000E+00 + 43 1.0716000E+00 1.0716000E+00 + 44 9.9963000E-01 9.9963000E-01 + 45 9.2454000E-01 9.2454000E-01 + 46 8.4679000E-01 8.4679000E-01 + 47 7.6682000E-01 7.6682000E-01 + 48 6.8507000E-01 6.8507000E-01 + 49 6.0200000E-01 6.0200000E-01 + 50 5.1804000E-01 5.1804000E-01 + 51 4.3363000E-01 4.3363000E-01 +# ptot | Pa + 1 7.0634297E+04 + 2 7.0545721E+04 + 3 7.0383337E+04 + 4 7.0082107E+04 + 5 6.9653560E+04 + 6 6.9106674E+04 + 7 6.8443007E+04 + 8 6.7665411E+04 + 9 6.6777167E+04 + 10 6.5782098E+04 + 11 6.4684524E+04 + 12 6.3489153E+04 + 13 6.2201152E+04 + 14 6.0826089E+04 + 15 5.9369849E+04 + 16 5.7838630E+04 + 17 5.6238888E+04 + 18 5.4577264E+04 + 19 5.2860508E+04 + 20 5.1095478E+04 + 21 4.9289008E+04 + 22 4.7447867E+04 + 23 4.5578745E+04 + 24 4.3688121E+04 + 25 4.1782258E+04 + 26 3.9867182E+04 + 27 3.7948571E+04 + 28 3.6031806E+04 + 29 3.4121896E+04 + 30 3.2223471E+04 + 31 3.0340808E+04 + 32 2.8477772E+04 + 33 2.6637876E+04 + 34 2.4824250E+04 + 35 2.3039678E+04 + 36 2.1286610E+04 + 37 1.9567201E+04 + 38 1.7883327E+04 + 39 1.6236613E+04 + 40 1.4628464E+04 + 41 1.3060118E+04 + 42 1.1532768E+04 + 43 1.0047605E+04 + 44 8.6059162E+03 + 45 7.2091906E+03 + 46 5.8593388E+03 + 47 4.5590417E+03 + 48 3.3122024E+03 + 49 2.1247397E+03 + 50 1.0093519E+03 + 51 0.0000000E+00 +# johm | MA/m^2 + 1 -2.2734000E+00 + 2 -2.2437000E+00 + 3 -2.1789000E+00 + 4 -2.1151000E+00 + 5 -2.0631000E+00 + 6 -2.0121000E+00 + 7 -1.9564000E+00 + 8 -1.8898000E+00 + 9 -1.7910000E+00 + 10 -1.7048000E+00 + 11 -1.6499000E+00 + 12 -1.5844000E+00 + 13 -1.5190000E+00 + 14 -1.4487000E+00 + 15 -1.3726000E+00 + 16 -1.2956000E+00 + 17 -1.2172000E+00 + 18 -1.1397000E+00 + 19 -1.0635000E+00 + 20 -9.8684000E-01 + 21 -9.1273000E-01 + 22 -8.3963000E-01 + 23 -7.6882000E-01 + 24 -7.0067000E-01 + 25 -6.3859000E-01 + 26 -5.8260000E-01 + 27 -5.3238000E-01 + 28 -4.8767000E-01 + 29 -4.4168000E-01 + 30 -3.9714000E-01 + 31 -3.5745000E-01 + 32 -3.2085000E-01 + 33 -2.8866000E-01 + 34 -2.6069000E-01 + 35 -2.3637000E-01 + 36 -2.1593000E-01 + 37 -1.9936000E-01 + 38 -1.8653000E-01 + 39 -1.7754000E-01 + 40 -1.7249000E-01 + 41 -1.7115000E-01 + 42 -1.7326000E-01 + 43 -1.7866000E-01 + 44 -1.8688000E-01 + 45 -1.9759000E-01 + 46 -2.0932000E-01 + 47 -2.0175000E-01 + 48 -1.4865000E-01 + 49 -7.5949000E-02 + 50 -4.0337000E-02 + 51 -7.9067000E-02 +# jbs | MA/m^2 + 1 -0.0000000E+00 + 2 -2.0768000E-02 + 3 -5.8943000E-02 + 4 -9.2829000E-02 + 5 -1.1054000E-01 + 6 -1.2077000E-01 + 7 -1.2945000E-01 + 8 -1.3713000E-01 + 9 -1.4398000E-01 + 10 -1.5000000E-01 + 11 -1.5540000E-01 + 12 -1.6026000E-01 + 13 -1.6461000E-01 + 14 -1.6844000E-01 + 15 -1.7169000E-01 + 16 -1.7439000E-01 + 17 -1.7655000E-01 + 18 -1.7819000E-01 + 19 -1.7937000E-01 + 20 -1.8011000E-01 + 21 -1.8043000E-01 + 22 -1.8039000E-01 + 23 -1.7996000E-01 + 24 -1.7914000E-01 + 25 -1.7790000E-01 + 26 -1.7622000E-01 + 27 -1.7410000E-01 + 28 -1.7169000E-01 + 29 -1.6941000E-01 + 30 -1.6749000E-01 + 31 -1.6590000E-01 + 32 -1.6465000E-01 + 33 -1.6363000E-01 + 34 -1.6270000E-01 + 35 -1.6181000E-01 + 36 -1.6101000E-01 + 37 -1.6030000E-01 + 38 -1.5959000E-01 + 39 -1.5851000E-01 + 40 -1.5683000E-01 + 41 -1.5457000E-01 + 42 -1.5181000E-01 + 43 -1.4866000E-01 + 44 -1.4519000E-01 + 45 -1.4145000E-01 + 46 -1.3871000E-01 + 47 -1.5714000E-01 + 48 -2.2276000E-01 + 49 -3.0976000E-01 + 50 -3.5074000E-01 + 51 -3.5143000E-01 +# jrf | MA/m^2 + 1 -0.0000000E+00 + 2 -0.0000000E+00 + 3 -0.0000000E+00 + 4 -0.0000000E+00 + 5 -0.0000000E+00 + 6 -0.0000000E+00 + 7 -0.0000000E+00 + 8 -6.1556000E-03 + 9 -3.9634000E-02 + 10 -5.6237000E-02 + 11 -3.7718000E-02 + 12 -2.6936000E-02 + 13 -1.4030000E-02 + 14 -5.2180000E-03 + 15 -1.5523000E-03 + 16 -4.3985000E-08 + 17 -0.0000000E+00 + 18 -0.0000000E+00 + 19 -0.0000000E+00 + 20 -0.0000000E+00 + 21 -0.0000000E+00 + 22 -6.6977000E-04 + 23 -2.3714000E-03 + 24 -5.1689000E-03 + 25 -5.8429000E-03 + 26 -4.4942000E-03 + 27 -1.4891000E-03 + 28 2.7864000E-03 + 29 3.0660000E-03 + 30 1.2366000E-03 + 31 3.2256000E-04 + 32 2.5979000E-08 + 33 -0.0000000E+00 + 34 -0.0000000E+00 + 35 -0.0000000E+00 + 36 -0.0000000E+00 + 37 -0.0000000E+00 + 38 -0.0000000E+00 + 39 -0.0000000E+00 + 40 -0.0000000E+00 + 41 -0.0000000E+00 + 42 -0.0000000E+00 + 43 -0.0000000E+00 + 44 -0.0000000E+00 + 45 -0.0000000E+00 + 46 -0.0000000E+00 + 47 -0.0000000E+00 + 48 -0.0000000E+00 + 49 -0.0000000E+00 + 50 -0.0000000E+00 + 51 -0.0000000E+00 +# jnb | MA/m^2 + 1 -1.1775000E-01 + 2 -1.2209000E-01 + 3 -1.3510000E-01 + 4 -1.4235000E-01 + 5 -1.4538000E-01 + 6 -1.4671000E-01 + 7 -1.4637000E-01 + 8 -1.4463000E-01 + 9 -1.4200000E-01 + 10 -1.3861000E-01 + 11 -1.3438000E-01 + 12 -1.2926000E-01 + 13 -1.2314000E-01 + 14 -1.1553000E-01 + 15 -1.0732000E-01 + 16 -9.7340000E-02 + 17 -8.7351000E-02 + 18 -7.7418000E-02 + 19 -6.7718000E-02 + 20 -6.0582000E-02 + 21 -5.3553000E-02 + 22 -4.7957000E-02 + 23 -4.2573000E-02 + 24 -3.7361000E-02 + 25 -3.2417000E-02 + 26 -2.7883000E-02 + 27 -2.3880000E-02 + 28 -2.0227000E-02 + 29 -1.8086000E-02 + 30 -1.6417000E-02 + 31 -1.4820000E-02 + 32 -1.4687000E-02 + 33 -1.4443000E-02 + 34 -1.3983000E-02 + 35 -1.3800000E-02 + 36 -1.3476000E-02 + 37 -1.2878000E-02 + 38 -1.2062000E-02 + 39 -1.1154000E-02 + 40 -1.0109000E-02 + 41 -8.9740000E-03 + 42 -7.7903000E-03 + 43 -6.4231000E-03 + 44 -5.1231000E-03 + 45 -3.9837000E-03 + 46 -3.0421000E-03 + 47 -2.2739000E-03 + 48 -1.6710000E-03 + 49 -1.1379000E-03 + 50 -6.5160000E-04 + 51 -1.7536000E-04 +# z_eff | - + 1 1.7987000E+00 + 2 1.7989000E+00 + 3 1.7994000E+00 + 4 1.8004000E+00 + 5 1.8017000E+00 + 6 1.8034000E+00 + 7 1.8055000E+00 + 8 1.8080000E+00 + 9 1.8110000E+00 + 10 1.8145000E+00 + 11 1.8184000E+00 + 12 1.8228000E+00 + 13 1.8278000E+00 + 14 1.8333000E+00 + 15 1.8394000E+00 + 16 1.8461000E+00 + 17 1.8535000E+00 + 18 1.8615000E+00 + 19 1.8702000E+00 + 20 1.8797000E+00 + 21 1.8900000E+00 + 22 1.9010000E+00 + 23 1.9130000E+00 + 24 1.9259000E+00 + 25 1.9398000E+00 + 26 1.9547000E+00 + 27 1.9707000E+00 + 28 1.9878000E+00 + 29 2.0060000E+00 + 30 2.0256000E+00 + 31 2.0464000E+00 + 32 2.0685000E+00 + 33 2.0920000E+00 + 34 2.1160000E+00 + 35 2.1392000E+00 + 36 2.1604000E+00 + 37 2.1782000E+00 + 38 2.1911000E+00 + 39 2.1975000E+00 + 40 2.1958000E+00 + 41 2.1843000E+00 + 42 2.1610000E+00 + 43 2.1241000E+00 + 44 2.0717000E+00 + 45 2.0016000E+00 + 46 1.9120000E+00 + 47 1.8012000E+00 + 48 1.6825000E+00 + 49 1.5737000E+00 + 50 1.4820000E+00 + 51 1.4387000E+00 +# vpol | m/s + 1 0.0000000E+00 -0.0000000E+00 + 2 0.0000000E+00 -2.1285800E+00 + 3 0.0000000E+00 -1.5742300E+01 + 4 0.0000000E+00 -4.8764300E+01 + 5 0.0000000E+00 -1.0524000E+02 + 6 0.0000000E+00 -1.8550900E+02 + 7 0.0000000E+00 -2.8636500E+02 + 8 0.0000000E+00 -4.0268700E+02 + 9 0.0000000E+00 -5.3080800E+02 + 10 0.0000000E+00 -6.6736600E+02 + 11 0.0000000E+00 -8.0886000E+02 + 12 0.0000000E+00 -9.5181700E+02 + 13 0.0000000E+00 -1.0928300E+03 + 14 0.0000000E+00 -1.2286000E+03 + 15 0.0000000E+00 -1.3559400E+03 + 16 0.0000000E+00 -1.4719200E+03 + 17 0.0000000E+00 -1.5738000E+03 + 18 0.0000000E+00 -1.6591300E+03 + 19 0.0000000E+00 -1.7257100E+03 + 20 0.0000000E+00 -1.7716300E+03 + 21 0.0000000E+00 -1.7952600E+03 + 22 0.0000000E+00 -1.7952000E+03 + 23 0.0000000E+00 -1.7703500E+03 + 24 0.0000000E+00 -1.7197900E+03 + 25 0.0000000E+00 -1.6428000E+03 + 26 0.0000000E+00 -1.5387900E+03 + 27 0.0000000E+00 -1.4073300E+03 + 28 0.0000000E+00 -1.2480800E+03 + 29 0.0000000E+00 -1.0607700E+03 + 30 0.0000000E+00 -8.4558000E+02 + 31 0.0000000E+00 -6.0403000E+02 + 32 0.0000000E+00 -3.3790700E+02 + 33 0.0000000E+00 -4.8955000E+01 + 34 0.0000000E+00 2.6113800E+02 + 35 0.0000000E+00 5.9078200E+02 + 36 0.0000000E+00 9.3846100E+02 + 37 0.0000000E+00 1.3026800E+03 + 38 0.0000000E+00 1.6819600E+03 + 39 0.0000000E+00 2.0749600E+03 + 40 0.0000000E+00 2.4807300E+03 + 41 0.0000000E+00 2.8985600E+03 + 42 0.0000000E+00 3.3273400E+03 + 43 0.0000000E+00 3.7646700E+03 + 44 0.0000000E+00 4.2075500E+03 + 45 0.0000000E+00 4.6534800E+03 + 46 0.0000000E+00 5.1036100E+03 + 47 0.0000000E+00 5.5624700E+03 + 48 0.0000000E+00 6.0348700E+03 + 49 0.0000000E+00 6.5227100E+03 + 50 0.0000000E+00 7.0198100E+03 + 51 0.0000000E+00 7.5138100E+03 +# vtor | m/s + 1 0.0000000E+00 -1.1560000E+05 + 2 0.0000000E+00 -1.1545200E+05 + 3 0.0000000E+00 -1.1363100E+05 + 4 0.0000000E+00 -1.1086400E+05 + 5 0.0000000E+00 -1.0790300E+05 + 6 0.0000000E+00 -1.0526500E+05 + 7 0.0000000E+00 -1.0296900E+05 + 8 0.0000000E+00 -1.0097700E+05 + 9 0.0000000E+00 -9.9247600E+04 + 10 0.0000000E+00 -9.7737700E+04 + 11 0.0000000E+00 -9.6403600E+04 + 12 0.0000000E+00 -9.5199900E+04 + 13 0.0000000E+00 -9.4080100E+04 + 14 0.0000000E+00 -9.2995800E+04 + 15 0.0000000E+00 -9.1897400E+04 + 16 0.0000000E+00 -9.0733900E+04 + 17 0.0000000E+00 -8.9454700E+04 + 18 0.0000000E+00 -8.8040700E+04 + 19 0.0000000E+00 -8.6497400E+04 + 20 0.0000000E+00 -8.4831700E+04 + 21 0.0000000E+00 -8.3050600E+04 + 22 0.0000000E+00 -8.1161300E+04 + 23 0.0000000E+00 -7.9171300E+04 + 24 0.0000000E+00 -7.7088600E+04 + 25 0.0000000E+00 -7.4921400E+04 + 26 0.0000000E+00 -7.2678100E+04 + 27 0.0000000E+00 -7.0367500E+04 + 28 0.0000000E+00 -6.7998500E+04 + 29 0.0000000E+00 -6.5580500E+04 + 30 0.0000000E+00 -6.3123100E+04 + 31 0.0000000E+00 -6.0636100E+04 + 32 0.0000000E+00 -5.8129400E+04 + 33 0.0000000E+00 -5.5613500E+04 + 34 0.0000000E+00 -5.3098700E+04 + 35 0.0000000E+00 -5.0595800E+04 + 36 0.0000000E+00 -4.8115600E+04 + 37 0.0000000E+00 -4.5669200E+04 + 38 0.0000000E+00 -4.3267600E+04 + 39 0.0000000E+00 -4.0922300E+04 + 40 0.0000000E+00 -3.8644600E+04 + 41 0.0000000E+00 -3.6446000E+04 + 42 0.0000000E+00 -3.4337900E+04 + 43 0.0000000E+00 -3.2332100E+04 + 44 0.0000000E+00 -3.0440200E+04 + 45 0.0000000E+00 -2.5201100E+04 + 46 0.0000000E+00 -8.2083000E+03 + 47 0.0000000E+00 9.2795700E+03 + 48 0.0000000E+00 1.4400800E+04 + 49 0.0000000E+00 6.9058100E+03 + 50 0.0000000E+00 -9.0772700E+03 + 51 0.0000000E+00 -2.9360900E+04 +# qohme | MW/m^3 + 1 2.5158000E-02 + 2 2.6135000E-02 + 3 2.7225000E-02 + 4 2.8505000E-02 + 5 2.9208000E-02 + 6 2.9470000E-02 + 7 2.9504000E-02 + 8 2.9080000E-02 + 9 2.7843000E-02 + 10 2.6810000E-02 + 11 2.6442000E-02 + 12 2.5886000E-02 + 13 2.5223000E-02 + 14 2.4363000E-02 + 15 2.3272000E-02 + 16 2.2066000E-02 + 17 2.0763000E-02 + 18 1.9420000E-02 + 19 1.8050000E-02 + 20 1.6627000E-02 + 21 1.5219000E-02 + 22 1.3808000E-02 + 23 1.2426000E-02 + 24 1.1104000E-02 + 25 9.9258000E-03 + 26 8.9009000E-03 + 27 8.0191000E-03 + 28 7.2462000E-03 + 29 6.4411000E-03 + 30 5.6568000E-03 + 31 4.9706000E-03 + 32 4.3599000E-03 + 33 3.8440000E-03 + 34 3.4188000E-03 + 35 3.0705000E-03 + 36 2.8007000E-03 + 37 2.6090000E-03 + 38 2.4953000E-03 + 39 2.4679000E-03 + 40 2.5390000E-03 + 41 2.7178000E-03 + 42 3.0178000E-03 + 43 3.4585000E-03 + 44 4.0536000E-03 + 45 4.8157000E-03 + 46 5.6879000E-03 + 47 5.5331000E-03 + 48 3.2078000E-03 + 49 9.6762000E-04 + 50 6.5075000E-04 + 51 2.4637000E-03 +# qbeame | MW/m^3 + 1 7.0804000E-02 + 2 7.0994000E-02 + 3 7.1307000E-02 + 4 7.1186000E-02 + 5 7.0821000E-02 + 6 7.0175000E-02 + 7 6.9264000E-02 + 8 6.8102000E-02 + 9 6.6688000E-02 + 10 6.5112000E-02 + 11 6.3480000E-02 + 12 6.1854000E-02 + 13 6.0312000E-02 + 14 5.8927000E-02 + 15 5.7731000E-02 + 16 5.6726000E-02 + 17 5.5872000E-02 + 18 5.5101000E-02 + 19 5.4318000E-02 + 20 5.3421000E-02 + 21 5.2354000E-02 + 22 5.1089000E-02 + 23 4.9631000E-02 + 24 4.8019000E-02 + 25 4.6313000E-02 + 26 4.4571000E-02 + 27 4.2843000E-02 + 28 4.1156000E-02 + 29 3.9520000E-02 + 30 3.7920000E-02 + 31 3.6332000E-02 + 32 3.4727000E-02 + 33 3.3080000E-02 + 34 3.1371000E-02 + 35 2.9593000E-02 + 36 2.7744000E-02 + 37 2.5833000E-02 + 38 2.3872000E-02 + 39 2.1880000E-02 + 40 1.9875000E-02 + 41 1.7872000E-02 + 42 1.5880000E-02 + 43 1.3903000E-02 + 44 1.1952000E-02 + 45 1.0077000E-02 + 46 8.3726000E-03 + 47 6.8796000E-03 + 48 5.7128000E-03 + 49 4.6720000E-03 + 50 3.8592000E-03 + 51 5.0383000E-03 +# qbeami | MW/m^3 + 1 2.7387000E-01 + 2 2.7361000E-01 + 3 2.7297000E-01 + 4 2.7059000E-01 + 5 2.6695000E-01 + 6 2.6183000E-01 + 7 2.5528000E-01 + 8 2.4740000E-01 + 9 2.3842000E-01 + 10 2.2867000E-01 + 11 2.1851000E-01 + 12 2.0835000E-01 + 13 1.9853000E-01 + 14 1.8930000E-01 + 15 1.8079000E-01 + 16 1.7299000E-01 + 17 1.6576000E-01 + 18 1.5882000E-01 + 19 1.5190000E-01 + 20 1.4475000E-01 + 21 1.3721000E-01 + 22 1.2923000E-01 + 23 1.2091000E-01 + 24 1.1243000E-01 + 25 1.0399000E-01 + 26 9.5781000E-02 + 27 8.7939000E-02 + 28 8.0530000E-02 + 29 7.3567000E-02 + 30 6.7020000E-02 + 31 6.0837000E-02 + 32 5.4967000E-02 + 33 4.9369000E-02 + 34 4.4023000E-02 + 35 3.8927000E-02 + 36 3.4095000E-02 + 37 2.9549000E-02 + 38 2.5315000E-02 + 39 2.1419000E-02 + 40 1.7879000E-02 + 41 1.4705000E-02 + 42 1.1889000E-02 + 43 9.4201000E-03 + 44 7.2877000E-03 + 45 5.4736000E-03 + 46 3.9670000E-03 + 47 2.7868000E-03 + 48 1.8625000E-03 + 49 9.9757000E-04 + 50 3.1434000E-04 + 51 4.5309000E-05 +# qrfe | MW/m^3 + 1 0.0000000E+00 + 2 0.0000000E+00 + 3 0.0000000E+00 + 4 0.0000000E+00 + 5 0.0000000E+00 + 6 0.0000000E+00 + 7 0.0000000E+00 + 8 1.2178000E-01 + 9 1.0689000E+00 + 10 1.7614000E+00 + 11 1.5338000E+00 + 12 1.2785000E+00 + 13 8.1078000E-01 + 14 4.2075000E-01 + 15 1.1403000E-01 + 16 3.2311000E-06 + 17 2.4553000E-13 + 18 3.0872000E-13 + 19 3.7691000E-13 + 20 4.3567000E-13 + 21 4.6529000E-13 + 22 7.5583000E-02 + 23 2.2327000E-01 + 24 5.2413000E-01 + 25 8.3604000E-01 + 26 9.9789000E-01 + 27 1.0632000E+00 + 28 8.4740000E-01 + 29 4.3765000E-01 + 30 1.5820000E-01 + 31 4.3107000E-02 + 32 3.4719000E-06 + 33 2.1322000E-19 + 34 0.0000000E+00 + 35 0.0000000E+00 + 36 0.0000000E+00 + 37 0.0000000E+00 + 38 0.0000000E+00 + 39 0.0000000E+00 + 40 0.0000000E+00 + 41 0.0000000E+00 + 42 0.0000000E+00 + 43 0.0000000E+00 + 44 0.0000000E+00 + 45 0.0000000E+00 + 46 0.0000000E+00 + 47 0.0000000E+00 + 48 0.0000000E+00 + 49 0.0000000E+00 + 50 0.0000000E+00 + 51 0.0000000E+00 +# qbrem | MW/m^3 + 1 -1.0876120E-02 + 2 -1.0864610E-02 + 3 -1.0832930E-02 + 4 -1.0780460E-02 + 5 -1.0710150E-02 + 6 -1.0622990E-02 + 7 -1.0519980E-02 + 8 -1.0402110E-02 + 9 -1.0270380E-02 + 10 -1.0125760E-02 + 11 -9.9702600E-03 + 12 -9.8028700E-03 + 13 -9.6264700E-03 + 14 -9.4413500E-03 + 15 -9.2488900E-03 + 16 -9.0501700E-03 + 17 -8.8465900E-03 + 18 -8.6392200E-03 + 19 -8.4293500E-03 + 20 -8.2181600E-03 + 21 -8.0066400E-03 + 22 -7.7957600E-03 + 23 -7.5864200E-03 + 24 -7.3794700E-03 + 25 -7.1757100E-03 + 26 -6.9759800E-03 + 27 -6.7811500E-03 + 28 -6.5918800E-03 + 29 -6.4091000E-03 + 30 -6.2336600E-03 + 31 -6.0663600E-03 + 32 -5.9087200E-03 + 33 -5.7579200E-03 + 34 -5.6166900E-03 + 35 -5.4819300E-03 + 36 -5.3506700E-03 + 37 -5.2205000E-03 + 38 -5.0888900E-03 + 39 -4.9534200E-03 + 40 -4.8115700E-03 + 41 -4.6603900E-03 + 42 -4.4959500E-03 + 43 -4.3132500E-03 + 44 -4.1044900E-03 + 45 -3.8577700E-03 + 46 -3.5524200E-03 + 47 -3.1516500E-03 + 48 -2.5346700E-03 + 49 -1.4651000E-03 + 50 2.5980000E-04 + 51 1.8988000E-03 +# qei | MW/m^3 + 1 1.0324000E-01 + 2 1.0335000E-01 + 3 1.0366000E-01 + 4 1.0417000E-01 + 5 1.0485000E-01 + 6 1.0568000E-01 + 7 1.0662000E-01 + 8 1.0767000E-01 + 9 1.0879000E-01 + 10 1.0995000E-01 + 11 1.1112000E-01 + 12 1.1228000E-01 + 13 1.1338000E-01 + 14 1.1441000E-01 + 15 1.1533000E-01 + 16 1.1610000E-01 + 17 1.1670000E-01 + 18 1.1709000E-01 + 19 1.1725000E-01 + 20 1.1712000E-01 + 21 1.1666000E-01 + 22 1.1580000E-01 + 23 1.1449000E-01 + 24 1.1264000E-01 + 25 1.1017000E-01 + 26 1.0695000E-01 + 27 1.0285000E-01 + 28 9.7740000E-02 + 29 9.1543000E-02 + 30 8.4305000E-02 + 31 7.6085000E-02 + 32 6.6942000E-02 + 33 5.6942000E-02 + 34 4.6166000E-02 + 35 3.4717000E-02 + 36 2.2726000E-02 + 37 1.0373000E-02 + 38 -2.0906000E-03 + 39 -1.4454000E-02 + 40 -2.6669000E-02 + 41 -3.8679000E-02 + 42 -5.0373000E-02 + 43 -6.1570000E-02 + 44 -7.1961000E-02 + 45 -8.1079000E-02 + 46 -8.8210000E-02 + 47 -9.3144000E-02 + 48 -1.0254000E-01 + 49 -1.2983000E-01 + 50 -1.9565000E-01 + 51 -6.4078000E-01 +# qione | MW/m^3 + 1 -2.1888000E-04 + 2 -2.1839000E-04 + 3 -2.1807000E-04 + 4 -2.1754000E-04 + 5 -2.1685000E-04 + 6 -2.1601000E-04 + 7 -2.1502000E-04 + 8 -2.1389000E-04 + 9 -2.1262000E-04 + 10 -2.1124000E-04 + 11 -2.0974000E-04 + 12 -2.0813000E-04 + 13 -2.0643000E-04 + 14 -2.0465000E-04 + 15 -2.0281000E-04 + 16 -2.0093000E-04 + 17 -1.9901000E-04 + 18 -1.9708000E-04 + 19 -1.9515000E-04 + 20 -1.9324000E-04 + 21 -1.9136000E-04 + 22 -1.8954000E-04 + 23 -1.8778000E-04 + 24 -1.8613000E-04 + 25 -1.8459000E-04 + 26 -1.8322000E-04 + 27 -1.8205000E-04 + 28 -1.8112000E-04 + 29 -1.8050000E-04 + 30 -1.8024000E-04 + 31 -1.8044000E-04 + 32 -1.8118000E-04 + 33 -1.8258000E-04 + 34 -1.8481000E-04 + 35 -1.8807000E-04 + 36 -1.9263000E-04 + 37 -1.9880000E-04 + 38 -2.0701000E-04 + 39 -2.1778000E-04 + 40 -2.3183000E-04 + 41 -2.5011000E-04 + 42 -2.7395000E-04 + 43 -3.0555000E-04 + 44 -3.4841000E-04 + 45 -4.0873000E-04 + 46 -5.0098000E-04 + 47 -6.5745000E-04 + 48 -9.8023000E-04 + 49 -1.7976000E-03 + 50 -3.8250000E-03 + 51 -4.5038000E-03 +# qioni | MW/m^3 + 1 9.4531000E-03 + 2 9.3701000E-03 + 3 9.3325000E-03 + 4 9.2634000E-03 + 5 9.1706000E-03 + 6 9.0530000E-03 + 7 8.9109000E-03 + 8 8.7477000E-03 + 9 8.5676000E-03 + 10 8.3750000E-03 + 11 8.1747000E-03 + 12 7.9711000E-03 + 13 7.7688000E-03 + 14 7.5721000E-03 + 15 7.3842000E-03 + 16 7.2063000E-03 + 17 7.0397000E-03 + 18 6.8856000E-03 + 19 6.7442000E-03 + 20 6.6160000E-03 + 21 6.5015000E-03 + 22 6.4028000E-03 + 23 6.3227000E-03 + 24 6.2643000E-03 + 25 6.2307000E-03 + 26 6.2260000E-03 + 27 6.2543000E-03 + 28 6.3202000E-03 + 29 6.4277000E-03 + 30 6.5809000E-03 + 31 6.7839000E-03 + 32 7.0419000E-03 + 33 7.3605000E-03 + 34 7.7464000E-03 + 35 8.2076000E-03 + 36 8.7544000E-03 + 37 9.4003000E-03 + 38 1.0162000E-02 + 39 1.1061000E-02 + 40 1.2126000E-02 + 41 1.3396000E-02 + 42 1.4920000E-02 + 43 1.6780000E-02 + 44 1.9096000E-02 + 45 2.2050000E-02 + 46 2.5974000E-02 + 47 3.1422000E-02 + 48 3.9028000E-02 + 49 4.8356000E-02 + 50 5.1634000E-02 + 51 1.9069000E-02 +# qcxi | MW/m^3 + 1 -1.7823000E-02 + 2 -1.7444000E-02 + 3 -1.7300000E-02 + 4 -1.6984000E-02 + 5 -1.6569000E-02 + 6 -1.6034000E-02 + 7 -1.5373000E-02 + 8 -1.4603000E-02 + 9 -1.3762000E-02 + 10 -1.2876000E-02 + 11 -1.1968000E-02 + 12 -1.1077000E-02 + 13 -1.0229000E-02 + 14 -9.4424000E-03 + 15 -8.7256000E-03 + 16 -8.0759000E-03 + 17 -7.4838000E-03 + 18 -6.9324000E-03 + 19 -6.4104000E-03 + 20 -5.9062000E-03 + 21 -5.4133000E-03 + 22 -4.9272000E-03 + 23 -4.4610000E-03 + 24 -4.0202000E-03 + 25 -3.6228000E-03 + 26 -3.2800000E-03 + 27 -3.0015000E-03 + 28 -2.8131000E-03 + 29 -2.7133000E-03 + 30 -2.6980000E-03 + 31 -2.7619000E-03 + 32 -2.9081000E-03 + 33 -3.1237000E-03 + 34 -3.4068000E-03 + 35 -3.7571000E-03 + 36 -4.1720000E-03 + 37 -4.6470000E-03 + 38 -5.1724000E-03 + 39 -5.7465000E-03 + 40 -6.3775000E-03 + 41 -7.0834000E-03 + 42 -7.8863000E-03 + 43 -8.8662000E-03 + 44 -1.0157000E-02 + 45 -1.2077000E-02 + 46 -1.5777000E-02 + 47 -2.3931000E-02 + 48 -4.7796000E-02 + 49 -1.1997000E-01 + 50 -2.8813000E-01 + 51 -3.7280000E-01 +# qpar_beam | MW/m^3 + 1 1.6765000E+19 + 2 1.6758000E+19 + 3 1.6752000E+19 + 4 1.6641000E+19 + 5 1.6458000E+19 + 6 1.6195000E+19 + 7 1.5856000E+19 + 8 1.5447000E+19 + 9 1.4962000E+19 + 10 1.4428000E+19 + 11 1.3881000E+19 + 12 1.3333000E+19 + 13 1.2808000E+19 + 14 1.2320000E+19 + 15 1.1877000E+19 + 16 1.1480000E+19 + 17 1.1118000E+19 + 18 1.0781000E+19 + 19 1.0444000E+19 + 20 1.0087000E+19 + 21 9.6990000E+18 + 22 9.2854000E+18 + 23 8.8405000E+18 + 24 8.3869000E+18 + 25 7.9287000E+18 + 26 7.4873000E+18 + 27 7.0833000E+18 + 28 6.6914000E+18 + 29 6.3187000E+18 + 30 5.9591000E+18 + 31 5.6078000E+18 + 32 5.2390000E+18 + 33 4.8743000E+18 + 34 4.5134000E+18 + 35 4.1525000E+18 + 36 3.7947000E+18 + 37 3.4429000E+18 + 38 3.0974000E+18 + 39 2.7485000E+18 + 40 2.4173000E+18 + 41 2.1054000E+18 + 42 1.8133000E+18 + 43 1.5379000E+18 + 44 1.2829000E+18 + 45 1.0520000E+18 + 46 8.5218000E+17 + 47 6.9030000E+17 + 48 5.8057000E+17 + 49 5.1690000E+17 + 50 5.3095000E+17 + 51 5.4469000E+17 +# qpar_wall | MW/m^3 + 1 1.3314000E+19 + 2 1.3149000E+19 + 3 1.3141000E+19 + 4 1.3108000E+19 + 5 1.3071000E+19 + 6 1.3025000E+19 + 7 1.2963000E+19 + 8 1.2891000E+19 + 9 1.2811000E+19 + 10 1.2729000E+19 + 11 1.2650000E+19 + 12 1.2579000E+19 + 13 1.2523000E+19 + 14 1.2488000E+19 + 15 1.2477000E+19 + 16 1.2494000E+19 + 17 1.2538000E+19 + 18 1.2612000E+19 + 19 1.2716000E+19 + 20 1.2849000E+19 + 21 1.3012000E+19 + 22 1.3210000E+19 + 23 1.3450000E+19 + 24 1.3740000E+19 + 25 1.4089000E+19 + 26 1.4511000E+19 + 27 1.5022000E+19 + 28 1.5641000E+19 + 29 1.6389000E+19 + 30 1.7291000E+19 + 31 1.8379000E+19 + 32 1.9691000E+19 + 33 2.1273000E+19 + 34 2.3182000E+19 + 35 2.5488000E+19 + 36 2.8285000E+19 + 37 3.1695000E+19 + 38 3.5877000E+19 + 39 4.1042000E+19 + 40 4.7479000E+19 + 41 5.5586000E+19 + 42 6.5930000E+19 + 43 7.9454000E+19 + 44 9.7686000E+19 + 45 1.2335000E+20 + 46 1.6286000E+20 + 47 2.3066000E+20 + 48 3.7487000E+20 + 49 7.4741000E+20 + 50 1.6710000E+21 + 51 2.0012000E+21 +# qmom | MW/m^3 + 1 -2.9848000E-01 + 2 -2.9784000E-01 + 3 -2.9508000E-01 + 4 -2.9329000E-01 + 5 -2.9158000E-01 + 6 -2.8675000E-01 + 7 -2.8024000E-01 + 8 -2.7204000E-01 + 9 -2.6423000E-01 + 10 -2.5590000E-01 + 11 -2.4589000E-01 + 12 -2.3610000E-01 + 13 -2.2695000E-01 + 14 -2.1818000E-01 + 15 -2.1010000E-01 + 16 -2.0304000E-01 + 17 -1.9667000E-01 + 18 -1.9026000E-01 + 19 -1.8381000E-01 + 20 -1.7769000E-01 + 21 -1.7098000E-01 + 22 -1.6314000E-01 + 23 -1.5469000E-01 + 24 -1.4585000E-01 + 25 -1.3691000E-01 + 26 -1.2816000E-01 + 27 -1.1983000E-01 + 28 -1.1187000E-01 + 29 -1.0430000E-01 + 30 -9.7107000E-02 + 31 -9.0228000E-02 + 32 -8.3573000E-02 + 33 -7.7085000E-02 + 34 -7.0726000E-02 + 35 -6.4484000E-02 + 36 -5.8370000E-02 + 37 -5.2416000E-02 + 38 -4.6655000E-02 + 39 -4.1110000E-02 + 40 -3.5859000E-02 + 41 -3.0933000E-02 + 42 -2.6341000E-02 + 43 -2.2095000E-02 + 44 -1.8187000E-02 + 45 -1.4649000E-02 + 46 -1.1563000E-02 + 47 -8.9717000E-03 + 48 -6.8612000E-03 + 49 -4.7662000E-03 + 50 -2.9049000E-03 + 51 -3.3881000E-03 diff --git a/test/data/gacode/neo_reg18/input.neo b/test/data/gacode/neo_reg18/input.neo new file mode 100644 index 00000000..49b783ab --- /dev/null +++ b/test/data/gacode/neo_reg18/input.neo @@ -0,0 +1,13 @@ +N_ENERGY=6 +N_XI=17 +N_THETA=17 +N_RADIAL=1 +RMIN_OVER_A=0.5 +COLLISION_MODEL=4 +PROFILE_MODEL=2 +PROFILE_ERAD0_MODEL=1 + +ROTATION_MODEL=2 + +N_SPECIES=3 + diff --git a/test/data/gacode/neo_reg18/out.neo.diagnostic_geo b/test/data/gacode/neo_reg18/out.neo.diagnostic_geo new file mode 100644 index 00000000..82160577 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.diagnostic_geo @@ -0,0 +1,117 @@ +# I/psi' = -4.54294369E+00 +# <1/B^2>-1/ = 1.01660933E-01 +# f_trap = 5.63420110E-01 +# n_theta = 17 +# Functions: +# theta(:) +# v_drift_x(:) +# gradpar_Bmag(:) +# Bmag(:) +# w_theta(:) +# R(:) +# R(theta=0) +# dR(theta=0)/dr + -3.14159265E+00 + -2.77199352E+00 + -2.40239438E+00 + -2.03279525E+00 + -1.66319611E+00 + -1.29359698E+00 + -9.23997839E-01 + -5.54398704E-01 + -1.84799568E-01 + 1.84799568E-01 + 5.54398704E-01 + 9.23997839E-01 + 1.29359698E+00 + 1.66319611E+00 + 2.03279525E+00 + 2.40239438E+00 + 2.77199352E+00 + -2.01676844E-05 + 5.02724383E-04 + 9.25202602E-04 + 1.20700012E-03 + 1.34503052E-03 + 1.33648433E-03 + 1.18645789E-03 + 8.86861679E-04 + 3.90842869E-04 + -2.26862855E-04 + -7.67289237E-04 + -1.14777717E-03 + -1.37132578E-03 + -1.41726590E-03 + -1.28216430E-03 + -9.85630460E-04 + -5.48567241E-04 + -8.12516906E-04 + 1.97220590E-02 + 3.36063322E-02 + 3.92752170E-02 + 3.87124440E-02 + 3.42003751E-02 + 2.74656680E-02 + 1.90251504E-02 + 8.01641709E-03 + -4.62709028E-03 + -1.62019311E-02 + -2.59811383E-02 + -3.42670395E-02 + -3.99545786E-02 + -4.10868109E-02 + -3.54605779E-02 + -2.14242310E-02 + 8.92284019E-01 + 8.80494097E-01 + 8.47241679E-01 + 8.01900704E-01 + 7.54178514E-01 + 7.11129229E-01 + 6.76369345E-01 + 6.51105112E-01 + 6.36654714E-01 + 6.34873700E-01 + 6.45979738E-01 + 6.68829296E-01 + 7.02721037E-01 + 7.46401536E-01 + 7.95782393E-01 + 8.43200747E-01 + 8.78522580E-01 + 5.12321895E-02 + 5.22511709E-02 + 5.49089764E-02 + 5.80474511E-02 + 6.03882007E-02 + 6.14659268E-02 + 6.18124136E-02 + 6.20764985E-02 + 6.23481394E-02 + 6.24705331E-02 + 6.24623300E-02 + 6.23771503E-02 + 6.19803386E-02 + 6.07094880E-02 + 5.82052834E-02 + 5.49820417E-02 + 5.22818682E-02 + 2.39632373E+00 + 2.42412011E+00 + 2.50981684E+00 + 2.64306534E+00 + 2.80833821E+00 + 2.98721107E+00 + 3.15895429E+00 + 3.29919211E+00 + 3.38212667E+00 + 3.38979429E+00 + 3.32122662E+00 + 3.19157915E+00 + 3.02344646E+00 + 2.84053286E+00 + 2.66650480E+00 + 2.52388571E+00 + 2.43044453E+00 + 3.39587860E+00 + 9.23417076E-01 diff --git a/test/data/gacode/neo_reg18/out.neo.equil b/test/data/gacode/neo_reg18/out.neo.equil new file mode 100644 index 00000000..126a9f3b --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.equil @@ -0,0 +1 @@ + 0.50000000E+00 0.00000000E+00 -0.10843805E+01 0.43500326E-02 0.28962504E+01 -0.79858509E-01 0.11484609E+00 0.10000000E+01 0.10000000E+01 0.73344232E+00 0.15307771E+01 0.10151097E-02 0.37127148E-01 0.10000000E+01 -0.16735543E-01 0.15307771E+01 0.19940404E-01 0.12227629E+01 0.14848442E+01 0.59677497E+00 0.16062737E+01 0.41561908E-01 diff --git a/test/data/gacode/neo_reg18/out.neo.expnorm b/test/data/gacode/neo_reg18/out.neo.expnorm new file mode 100644 index 00000000..69cba7cc --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.expnorm @@ -0,0 +1 @@ + 0.50000000E+00 0.60301985E+00 0.33435800E+01 0.46095151E+01 0.20199722E+01 0.31111805E+06 0.24751190E+01 diff --git a/test/data/gacode/neo_reg18/out.neo.exprhon b/test/data/gacode/neo_reg18/out.neo.exprhon new file mode 100644 index 00000000..580e5864 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.exprhon @@ -0,0 +1 @@ + 0.50000000E+00 0.43655570E+00 0.37500765E+00 diff --git a/test/data/gacode/neo_reg18/out.neo.grid b/test/data/gacode/neo_reg18/out.neo.grid new file mode 100644 index 00000000..55d3e3aa --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.grid @@ -0,0 +1,23 @@ + 3 + 6 + 17 + 17 + -3.1415926535897931 + -2.7719935178733470 + -2.4023943821569005 + -2.0327952464404544 + -1.6631961107240081 + -1.2935969750075618 + -0.92399783929111556 + -0.55439870357466936 + -0.18479956785822305 + 0.18479956785822321 + 0.55439870357466947 + 0.92399783929111567 + 1.2935969750075620 + 1.6631961107240083 + 2.0327952464404544 + 2.4023943821569009 + 2.7719935178733470 + 1 + 0.50000000000000000 diff --git a/test/data/gacode/neo_reg18/out.neo.prec b/test/data/gacode/neo_reg18/out.neo.prec new file mode 100644 index 00000000..d535e99a --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.prec @@ -0,0 +1 @@ + 0.12268957E+02 diff --git a/test/data/gacode/neo_reg18/out.neo.rotation b/test/data/gacode/neo_reg18/out.neo.rotation new file mode 100644 index 00000000..e9f63d30 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.rotation @@ -0,0 +1 @@ + 0.50000000E+00 -0.70127075E-02 0.10024828E+01 -0.11861078E-02 0.10149465E+01 -0.70781065E-02 0.10047306E+01 -0.36242968E-02 -0.13632384E-01 -0.13317265E-01 -0.12322755E-01 -0.10707405E-01 -0.85870421E-02 -0.61463219E-02 -0.36600116E-02 -0.15258622E-02 -0.21974081E-03 -0.97331627E-04 -0.11820374E-02 -0.31718692E-02 -0.56334022E-02 -0.81589419E-02 -0.10414562E-01 -0.12156167E-01 -0.13245057E-01 0.99518270E+00 0.99529431E+00 0.99564644E+00 0.99621816E+00 0.99696814E+00 0.99783078E+00 0.99870881E+00 0.99946189E+00 0.99992252E+00 0.99996568E+00 0.99958317E+00 0.99888111E+00 0.99801198E+00 0.99711950E+00 0.99632177E+00 0.99570542E+00 0.99531988E+00 0.97144209E+00 0.97209592E+00 0.97416132E+00 0.97752241E+00 0.98194618E+00 0.98705507E+00 0.99227783E+00 0.99677571E+00 0.99953523E+00 0.99979412E+00 0.99750163E+00 0.99330542E+00 0.98813099E+00 0.98284098E+00 0.97813257E+00 0.97450758E+00 0.97224578E+00 0.99085764E+00 0.99106802E+00 0.99173228E+00 0.99281217E+00 0.99423144E+00 0.99586766E+00 0.99753722E+00 0.99897253E+00 0.99985197E+00 0.99993443E+00 0.99920396E+00 0.99786533E+00 0.99621186E+00 0.99451824E+00 0.99300806E+00 0.99184360E+00 0.99111624E+00 diff --git a/test/data/gacode/neo_reg18/out.neo.species b/test/data/gacode/neo_reg18/out.neo.species new file mode 100644 index 00000000..5734f58a --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.species @@ -0,0 +1 @@ + 0.10000000E+01 0.10000000E+01 0.60000000E+01 0.60000000E+01 0.27244370E-03 -0.10000000E+01 diff --git a/test/data/gacode/neo_reg18/out.neo.theory b/test/data/gacode/neo_reg18/out.neo.theory new file mode 100644 index 00000000..9166a5c3 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.theory @@ -0,0 +1 @@ + 0.50000000E+00 0.51836555E-08 0.29814909E-06 0.28136446E-07 -0.53097946E-01 0.10077134E+01 -0.14260914E-01 -0.79557281E-02 0.16176414E-05 0.44711163E-06 -0.56580238E-01 0.55592922E+00 -0.27927902E-01 -0.43889678E-02 0.17045915E-07 0.65705256E-07 0.75465069E-06 0.68767263E-08 0.36229984E-07 0.69413746E-08 0.28540178E-07 -0.56571525E-01 -0.55938465E-01 diff --git a/test/data/gacode/neo_reg18/out.neo.transport b/test/data/gacode/neo_reg18/out.neo.transport new file mode 100644 index 00000000..13aa0dda --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.transport @@ -0,0 +1 @@ + 0.50000000E+00 0.00000000E+00 -0.52366118E-01 -0.27118980E+00 -0.16728110E+00 0.72223583E-07 0.10842419E-05 0.16406582E-06 -0.22211333E-01 0.74260641E+00 0.39771736E-01 -0.58458984E-02 -0.48647377E-01 -0.98921413E-08 0.19749271E-07 0.42807900E-07 -0.73708276E-02 -0.48661860E+00 -0.15895546E-03 0.62930401E-03 -0.11365931E-01 0.61372981E-08 0.48773750E-07 0.16275646E-09 0.23455319E-01 0.87861621E+00 -0.89415903E-01 0.10748534E-01 0.59692958E-01 diff --git a/test/data/gacode/neo_reg18/out.neo.transport_exp b/test/data/gacode/neo_reg18/out.neo.transport_exp new file mode 100644 index 00000000..26e1a0c0 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.transport_exp @@ -0,0 +1 @@ + 0.30150993E+00 0.00000000E+00 -0.12032269E+06 -0.84372042E+05 -0.52044170E+05 0.10357608E+00 0.50323257E+04 0.14759345E-02 -0.69103465E+04 0.74260641E+00 0.23044056E+05 -0.18187645E+04 -0.15135077E+05 -0.14186353E-01 0.91662909E+02 0.38509945E-03 -0.22931975E+04 -0.48661860E+00 -0.92100039E+02 0.19578783E+03 -0.35361463E+04 0.88015197E-02 0.22637514E+03 0.14641556E-05 0.72973730E+04 0.87861621E+00 -0.51808274E+05 0.33440628E+04 0.18571557E+05 diff --git a/test/data/gacode/neo_reg18/out.neo.transport_flux b/test/data/gacode/neo_reg18/out.neo.transport_flux new file mode 100644 index 00000000..ea795f44 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.transport_flux @@ -0,0 +1,16 @@ +# r/a= 0.50000000E+00 +# Z pflux_dke eflux_dke mflux_dke +# (GB) (GB) (GB) + 1.000 0.17252E-02 0.17442E-01 0.32161E-02 + 6.000 -0.23629E-03 0.31770E-03 0.83914E-03 + -1.000 0.14660E-03 0.78462E-03 0.31904E-05 +# Z pflux_gv eflux_gv mflux_gv +# (GB) (GB) (GB) + 1.000 -0.15616E-03 -0.93765E-04 -0.41246E-02 + 6.000 -0.10071E-05 -0.10616E-05 -0.17674E-04 + -1.000 -0.77839E-07 -0.66034E-07 -0.31248E-05 +# Z pflux_tgyro eflux_tgyro mflux_tgyro +# (GB) (GB) (GB) + 1.000 0.15690E-02 0.17289E-01 -0.90846E-03 + 6.000 -0.23730E-03 0.37048E-03 0.82147E-03 + -1.000 0.14652E-03 0.78455E-03 0.65620E-07 diff --git a/test/data/gacode/neo_reg18/out.neo.transport_gv b/test/data/gacode/neo_reg18/out.neo.transport_gv new file mode 100644 index 00000000..2c0fde71 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.transport_gv @@ -0,0 +1 @@ + 0.50000000E+00 -0.65375911E-08 -0.58286806E-08 -0.21040990E-06 -0.42162394E-10 -0.65991707E-10 -0.90160880E-09 -0.32587182E-11 -0.41048225E-11 -0.15940895E-09 diff --git a/test/data/gacode/neo_reg18/out.neo.vel b/test/data/gacode/neo_reg18/out.neo.vel new file mode 100644 index 00000000..35cc24d5 --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.vel @@ -0,0 +1 @@ + -1.3898455494808686E-002 -8.9525985828562780E-003 7.0485016275960823E-003 -1.5067796404194670E-002 -8.9885154257679060E-003 8.9095225662287232E-003 -1.8469080224073200E-002 -9.1118502346794802E-003 1.4285863113801074E-002 -2.3396086743478026E-002 -9.3382636250992669E-003 2.1980356045702164E-002 -2.9019835027436849E-002 -9.6695465065316580E-003 3.0619026359697800E-002 -3.4568279854366141E-002 -1.0079922953960143E-002 3.8975030640112082E-002 -3.9443854301854396E-002 -1.0516695871167875E-002 4.6163356615810669E-002 -4.3259483209649584E-002 -1.0907180108630099E-002 5.1689415732975932E-002 -4.5574675104368551E-002 -1.1159807101451761E-002 5.5010419621576948E-002 -4.5855297207121115E-002 -1.1187811233965832E-002 5.5418626037031821E-002 -4.4053532459307640E-002 -1.0983167749349754E-002 5.2850920834421361E-002 -4.0550393965477861E-002 -1.0615633615213917E-002 4.7795665378488603E-002 -3.5711259207323556E-002 -1.0172258513560526E-002 4.0680673065649209E-002 -2.9988239017651092E-002 -9.7369910617040624E-003 3.2085671613657205E-002 -2.4092243799525820E-002 -9.3782427022675099E-003 2.3051623272466334E-002 -1.8894663321779370E-002 -9.1316096833297718E-003 1.4949989941219816E-002 -1.5265237980130002E-002 -8.9961318237976613E-003 9.2206526495730856E-003 diff --git a/test/data/gacode/neo_reg18/out.neo.version b/test/data/gacode/neo_reg18/out.neo.version new file mode 100644 index 00000000..6ca57bbe --- /dev/null +++ b/test/data/gacode/neo_reg18/out.neo.version @@ -0,0 +1,3 @@ +6357db30 [2026-07-22] +GFORTRAN_OSX_BREW +Mon Sep 7 16:57:27 KST 2026 diff --git a/test/data/gacode/neo_vest_48224/input.neo b/test/data/gacode/neo_vest_48224/input.neo new file mode 100644 index 00000000..a1d63595 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/input.neo @@ -0,0 +1,15 @@ +N_ENERGY=6 +N_XI=17 +N_THETA=17 +N_RADIAL=1 +RMIN_OVER_A=0.5 +SILENT_FLAG=0 +EQUILIBRIUM_MODEL=0 +COLLISION_MODEL=4 +PROFILE_MODEL=2 +PROFILE_ERAD0_MODEL=1 +ROTATION_MODEL=1 +SPITZER_MODEL=0 +IPCCW=-1 +BTCCW=-1 +N_SPECIES=2 diff --git a/test/data/gacode/neo_vest_48224/out.neo.diagnostic_geo b/test/data/gacode/neo_vest_48224/out.neo.diagnostic_geo new file mode 100644 index 00000000..246722bf --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.diagnostic_geo @@ -0,0 +1,117 @@ +# I/psi' = 3.20691525E+00 +# <1/B^2>-1/ = 6.65462727E-01 +# f_trap = 7.31847117E-01 +# n_theta = 17 +# Functions: +# theta(:) +# v_drift_x(:) +# gradpar_Bmag(:) +# Bmag(:) +# w_theta(:) +# R(:) +# R(theta=0) +# dR(theta=0)/dr + -3.14159265E+00 + -2.77199352E+00 + -2.40239438E+00 + -2.03279525E+00 + -1.66319611E+00 + -1.29359698E+00 + -9.23997839E-01 + -5.54398704E-01 + -1.84799568E-01 + 1.84799568E-01 + 5.54398704E-01 + 9.23997839E-01 + 1.29359698E+00 + 1.66319611E+00 + 2.03279525E+00 + 2.40239438E+00 + 2.77199352E+00 + -5.06906029E-06 + -9.94676148E-04 + -1.99430717E-03 + -2.87683499E-03 + -3.39906132E-03 + -3.42163005E-03 + -2.95267360E-03 + -2.02042883E-03 + -7.03544410E-04 + 7.67819388E-04 + 2.07876831E-03 + 3.01623544E-03 + 3.50230161E-03 + 3.48590741E-03 + 2.93971725E-03 + 2.01904391E-03 + 9.92934546E-04 + -2.17808682E-04 + -4.04171586E-02 + -6.93326349E-02 + -7.98025749E-02 + -7.32419743E-02 + -5.80307708E-02 + -4.10427032E-02 + -2.43854646E-02 + -7.89859348E-03 + 8.63403719E-03 + 2.51848885E-02 + 4.20911092E-02 + 5.95635813E-02 + 7.52505371E-02 + 8.16852601E-02 + 7.03097524E-02 + 4.03895779E-02 + -8.43096319E-01 + -8.19871865E-01 + -7.58362184E-01 + -6.77414321E-01 + -5.97040566E-01 + -5.29683176E-01 + -4.79527702E-01 + -4.46835054E-01 + -4.30955589E-01 + -4.31301328E-01 + -4.47682848E-01 + -4.80469702E-01 + -5.30416672E-01 + -5.97585702E-01 + -6.77988714E-01 + -7.58994700E-01 + -8.20310546E-01 + 4.41139325E-02 + 4.49603418E-02 + 4.72918902E-02 + 5.13405818E-02 + 5.70791933E-02 + 6.37438131E-02 + 6.97775432E-02 + 7.36858124E-02 + 7.50888428E-02 + 7.44960223E-02 + 7.21199853E-02 + 6.77409908E-02 + 6.17478848E-02 + 5.54452571E-02 + 5.01754285E-02 + 4.65710920E-02 + 4.46213880E-02 + 1.00729323E+00 + 1.03199379E+00 + 1.10581517E+00 + 1.22673885E+00 + 1.38788382E+00 + 1.57385250E+00 + 1.75901715E+00 + 1.91040874E+00 + 1.99607254E+00 + 1.99607254E+00 + 1.91040874E+00 + 1.75901715E+00 + 1.57385250E+00 + 1.38788382E+00 + 1.22673885E+00 + 1.10581517E+00 + 1.03199379E+00 + 2.00729323E+00 + 9.20834615E-01 diff --git a/test/data/gacode/neo_vest_48224/out.neo.equil b/test/data/gacode/neo_vest_48224/out.neo.equil new file mode 100644 index 00000000..42537b1f --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.equil @@ -0,0 +1 @@ + 0.50000000E+00 0.00000000E+00 0.19653386E+01 -0.51584494E-02 0.15072932E+01 0.00000000E+00 0.00000000E+00 0.10000000E+01 0.10000000E+01 0.26390981E+01 0.26031690E+00 0.36774627E+01 0.10000000E+01 0.11541000E+02 0.26390981E+01 -0.44214634E+00 0.40339038E+01 diff --git a/test/data/gacode/neo_vest_48224/out.neo.expnorm b/test/data/gacode/neo_vest_48224/out.neo.expnorm new file mode 100644 index 00000000..70573f42 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.expnorm @@ -0,0 +1 @@ + 0.50000000E+00 0.27731346E+00 0.33435800E+01 0.57418679E+00 0.88495275E-02 0.20592665E+05 -0.30041210E+00 diff --git a/test/data/gacode/neo_vest_48224/out.neo.exprhon b/test/data/gacode/neo_vest_48224/out.neo.exprhon new file mode 100644 index 00000000..16b7a347 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.exprhon @@ -0,0 +1 @@ + 0.50000000E+00 0.43321577E+00 0.29035646E+00 diff --git a/test/data/gacode/neo_vest_48224/out.neo.grid b/test/data/gacode/neo_vest_48224/out.neo.grid new file mode 100644 index 00000000..37c27b48 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.grid @@ -0,0 +1,23 @@ + 2 + 6 + 17 + 17 + -3.1415926535897931 + -2.7719935178733470 + -2.4023943821569005 + -2.0327952464404544 + -1.6631961107240081 + -1.2935969750075618 + -0.92399783929111556 + -0.55439870357466936 + -0.18479956785822305 + 0.18479956785822321 + 0.55439870357466947 + 0.92399783929111567 + 1.2935969750075620 + 1.6631961107240083 + 2.0327952464404544 + 2.4023943821569009 + 2.7719935178733470 + 1 + 0.50000000000000000 diff --git a/test/data/gacode/neo_vest_48224/out.neo.prec b/test/data/gacode/neo_vest_48224/out.neo.prec new file mode 100644 index 00000000..403af84d --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.prec @@ -0,0 +1 @@ + -0.43318709E+02 diff --git a/test/data/gacode/neo_vest_48224/out.neo.species b/test/data/gacode/neo_vest_48224/out.neo.species new file mode 100644 index 00000000..83f4d0c4 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.species @@ -0,0 +1 @@ + 0.50392000E+00 0.10000000E+01 0.27244370E-03 -0.10000000E+01 diff --git a/test/data/gacode/neo_vest_48224/out.neo.theory b/test/data/gacode/neo_vest_48224/out.neo.theory new file mode 100644 index 00000000..f0e7a7ea --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.theory @@ -0,0 +1 @@ + 0.50000000E+00 0.13171754E-04 0.13626028E-03 0.23709974E-03 -0.36133360E+00 -0.19887830E+01 -0.56528571E-01 -0.33078531E-02 0.93928542E-04 0.13725005E-03 -0.30002731E+00 -0.19231798E+01 -0.56246061E-01 -0.31987382E-02 0.23344893E-05 0.11060883E-04 0.43863307E-04 0.10181370E-04 0.15389619E-04 -0.29844522E+00 -0.28568933E+00 diff --git a/test/data/gacode/neo_vest_48224/out.neo.transport b/test/data/gacode/neo_vest_48224/out.neo.transport new file mode 100644 index 00000000..9a2b61c7 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.transport @@ -0,0 +1 @@ + 0.50000000E+00 0.45805933E-05 -0.28074010E+00 0.00000000E+00 0.00000000E+00 0.12968397E-04 0.99213814E-04 0.39287362E-06 -0.34723920E-01 0.30745922E+01 0.37159278E-01 0.51138307E-02 -0.10290351E+00 0.12968485E-04 0.16992973E-03 0.16774893E-07 0.24601618E+00 -0.20544449E+01 -0.48672241E+00 -0.66982355E-01 0.83412690E+00 diff --git a/test/data/gacode/neo_vest_48224/out.neo.version b/test/data/gacode/neo_vest_48224/out.neo.version new file mode 100644 index 00000000..26e3ba42 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.version @@ -0,0 +1,3 @@ +6357db30 [2026-07-22] +GFORTRAN_OSX_BREW +Fri Sep 11 15:27:24 KST 2026 diff --git a/test/test_external_code_environment.py b/test/test_external_code_environment.py index cfa2d8bd..d7a063ac 100644 --- a/test/test_external_code_environment.py +++ b/test/test_external_code_environment.py @@ -7,7 +7,7 @@ import pytest -from vaft.code import chease, efit, gpec, nubeam +from vaft.code import chease, efit, gacode, gpec, nubeam from vaft.code.tes import runner as tes_runner from vaft.code.tes.config import TESConfig @@ -24,6 +24,12 @@ "TESHOME", "RTES", "NUBEAMHOME", + # GACODE keeps its own root and platform variables; VAFT sets both from + # $GACODEHOME rather than redefining them, and accepts GACODE_ROOT as a + # compatibility fallback. + "GACODEHOME", + "GACODE_ROOT", + "GACODE_PLATFORM", # TokaMaker (Open FUSION Toolkit) is imported in-process rather than run # as a $XHOME/bin binary; these steer library discovery and sys.path. "OFT_ROOTPATH", @@ -48,17 +54,22 @@ def test_canonical_home_layouts_resolve_expected_executables(monkeypatch, tmp_pa efit_executable = _executable(tmp_path / "efit", "bin/efit") tes_executable = _executable(tmp_path / "tes", "bin/rtes") nubeam_executable = _executable(tmp_path / "nubeam", "bin/nubeam_comp_exec") + # GACODE is the one suite whose members each carry their own bin, so the + # documented layout is /neo/bin/neo rather than /bin/neo. + gacode_executable = _executable(tmp_path / "gacode", "neo/bin/neo") monkeypatch.setenv("GPECHOME", str(tmp_path / "gpec")) monkeypatch.setenv("CHEASEHOME", str(tmp_path / "chease")) monkeypatch.setenv("EFITHOME", str(tmp_path / "efit")) monkeypatch.setenv("TESHOME", str(tmp_path / "tes")) monkeypatch.setenv("NUBEAMHOME", str(tmp_path / "nubeam")) + monkeypatch.setenv("GACODEHOME", str(tmp_path / "gacode")) assert gpec._executable(gpec.GPECSuiteConfig(), "dcon") == gpec_executable assert chease.find_chease_executable() == chease_executable assert efit.find_efit_executable() == efit_executable assert tes_runner._resolve_executable(TESConfig()) == str(tes_executable) assert nubeam.find_nubeam_executable() == nubeam_executable + assert gacode.find_gacode_executable(gacode.GACODEConfig(), "neo") == gacode_executable def test_invalid_home_is_not_masked_by_legacy_executable(monkeypatch, tmp_path): @@ -91,6 +102,11 @@ def test_invalid_home_is_not_masked_by_legacy_executable(monkeypatch, tmp_path): lambda: tes_runner._resolve_executable(TESConfig()), ), ("NUBEAMHOME", "bin/nubeam_comp_exec", nubeam.find_nubeam_executable), + ( + "GACODEHOME", + "neo/bin/neo", + lambda: gacode.find_gacode_executable(gacode.GACODEConfig(), "neo"), + ), ], ) def test_each_adapter_reports_missing_home_executable( diff --git a/test/test_formula_catalog.py b/test/test_formula_catalog.py index b389744b..95aa7ac3 100644 --- a/test/test_formula_catalog.py +++ b/test/test_formula_catalog.py @@ -63,6 +63,7 @@ def test_the_catalog_counts_the_known_public_surface(): "atomic": 3, "statistics": 22, "magnetics": 2, + "neoclassical": 12, } assert len(catalog.list_formulas()) == sum(counts.values()) diff --git a/test/test_formula_docstrings.py b/test/test_formula_docstrings.py index df0341be..8bd05aac 100644 --- a/test/test_formula_docstrings.py +++ b/test/test_formula_docstrings.py @@ -122,6 +122,21 @@ "interpolate_adf11", "fractional_abundances", "line_cooling_coefficient", + # Neoclassical: the Coulomb-logarithm and collisionality conventions + # differ from the three already in the package (issue #353), and the + # bootstrap current carries the Wb-per-radian and COCOS sign choice. + "coulomb_logarithm_electron_sauter", + "coulomb_logarithm_ion_sauter", + "electron_collisionality_sauter", + "ion_collisionality_sauter", + "trapped_particle_fraction", + "sauter_spitzer_conductivity", + "sauter_neoclassical_conductivity", + "redl_neoclassical_conductivity", + "sauter_bootstrap_coefficients", + "redl_bootstrap_coefficients", + "sauter_bootstrap_current", + "redl_bootstrap_current", }) #: Fitted coefficients or scalings: the source dataset must be named. @@ -137,6 +152,17 @@ "current_drive_efficiency", "bootstrap_current_fraction", "alpha_heating_power_from_n_D_n_T_T_keV_V", + # Neoclassical: every one of these is a rational fit to numerical + # drift-kinetic solutions (Sauter 1999/2002, Redl 2021), and the + # trapped fraction and Spitzer charge factor are fits too. + "trapped_particle_fraction", + "sauter_spitzer_conductivity", + "sauter_neoclassical_conductivity", + "redl_neoclassical_conductivity", + "sauter_bootstrap_coefficients", + "redl_bootstrap_coefficients", + "sauter_bootstrap_current", + "redl_bootstrap_current", }) SPECS = catalog.list_formulas() diff --git a/test/test_formula_lazy_namespace.py b/test/test_formula_lazy_namespace.py index 12ac5054..3d07ce2f 100644 --- a/test/test_formula_lazy_namespace.py +++ b/test/test_formula_lazy_namespace.py @@ -33,6 +33,7 @@ "atomic", "statistics", "magnetics", + "neoclassical", ) diff --git a/test/test_formula_neoclassical.py b/test/test_formula_neoclassical.py new file mode 100644 index 00000000..769affee --- /dev/null +++ b/test/test_formula_neoclassical.py @@ -0,0 +1,679 @@ +"""Sauter and Redl neoclassical formulas, against NEO and against their limits. + +The decisive test here is `test_sauter_reproduces_neo_reg18` and its Redl twin. +NEO carries its own implementations of both formulations +(`compute_Sauter` and `compute_Sauter_mod` in `neo/src/neo_theory.f90`) and +writes both to `out.neo.theory` on every run, so a stored run of the shipped +`reg18` regression case is an independent reference for the whole chain: the +coefficient fits, the collisionality convention and the current assembly. + +The fixtures in `test/data/gacode/neo_reg18/` are a real NEO run of that case, +GACODE 6357db30, whose `out.neo.prec` reproduced the shipped reference value +exactly. Nothing here needs GACODE installed (issue #550). +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from vaft.formula.neoclassical import ( + BootstrapCoefficients, + coulomb_logarithm_electron_sauter, + coulomb_logarithm_ion_sauter, + electron_collisionality_sauter, + ion_collisionality_sauter, + redl_bootstrap_coefficients, + redl_bootstrap_current, + redl_neoclassical_conductivity, + sauter_bootstrap_coefficients, + sauter_bootstrap_current, + sauter_neoclassical_conductivity, + sauter_spitzer_conductivity, + trapped_particle_fraction, +) + +FIXTURES = Path(__file__).parent / "data" / "gacode" + +#: Two stored NEO runs, deliberately in different regimes: reg18 is GACODE's +#: own conventional-aspect-ratio regression case with a carbon impurity, and +#: vest_48224 is VEST's packaged kinetic state, whose trapped fraction is half +#: again as large. A fit that matches in only one of them is not verified. +NEO_RUNS = {"reg18": FIXTURES / "neo_reg18", "vest_48224": FIXTURES / "neo_vest_48224"} + + +def _diagnostic_geo(directory: Path) -> dict[str, float]: + """Read the named scalars from out.neo.diagnostic_geo's comment header.""" + values: dict[str, float] = {} + for line in (directory / "out.neo.diagnostic_geo").read_text().splitlines(): + if not line.startswith("#") or "=" not in line: + continue + name, _, number = line[1:].partition("=") + try: + values[name.strip()] = float(number) + except ValueError: + continue + return values + + +class _NeoRun: + """One stored NEO run, in NEO's own normalised units. + + Reconstructing NEO's two collisionalities is the only non-obvious part, and + it is done from NEO's own definitions in `neo/src/neo_theory.f90` rather + than from the paper, because the point of the comparison is that VAFT's + coefficient fits agree once both sides are handed the same numbers. + `nu(is)` is the fifth per-species column of out.neo.equil, and the two + `(4/3)/sqrt(pi)` and `(4/3)/sqrt(2 pi)` factors are NEO's. + """ + + def __init__(self, directory: Path) -> None: + equilibrium = np.loadtxt(directory / "out.neo.equil") + species = np.loadtxt(directory / "out.neo.species") + self.theory = np.loadtxt(directory / "out.neo.theory") + self.transport = np.loadtxt(directory / "out.neo.transport") + + geometry = _diagnostic_geo(directory) + self.f_trap = geometry["f_trap"] + self.i_over_psi_prime = geometry["I/psi'"] + + self.mass = species[0::2] + self.charge = species[1::2] + n_species = self.charge.size + + r_over_a, _dphidr, self.q, self.rho_star, self.rmaj = equilibrium[:5] + self.density = equilibrium[7 + 0 :: 5] + self.temperature = equilibrium[7 + 1 :: 5] + self.dlnndr = equilibrium[7 + 2 :: 5] + self.dlntdr = equilibrium[7 + 3 :: 5] + collision_rate = equilibrium[7 + 4 :: 5] + + self.electron = int(np.argmin(self.charge)) + self.ions = [i for i in range(n_species) if i != self.electron] + self.main_ion = self.ions[0] + + self.epsilon = r_over_a / self.rmaj + self.z_eff = sum( + self.density[i] * self.charge[i] ** 2 for i in self.ions + ) / self.density[self.electron] + + electron_rate = ( + collision_rate[self.main_ion] + * (4.0 / 3.0) + / np.sqrt(np.pi) + * np.sqrt(self.mass[self.main_ion] / self.mass[self.electron]) + * (self.temperature[self.main_ion] / self.temperature[self.electron]) ** 1.5 + / self.charge[self.main_ion] ** 2 + ) * (self.density[self.electron] / self.density[self.main_ion]) * self.z_eff / ( + self.charge[self.main_ion] ** 2 + ) + self.nu_e_star = ( + electron_rate + * self.rmaj + * abs(self.q) + / ( + self.epsilon**1.5 + * np.sqrt(self.temperature[self.electron] / self.mass[self.electron]) + ) + ) + + ion_rate = collision_rate[self.main_ion] * (4.0 / 3.0) / np.sqrt(2.0 * np.pi) + # NEO's 2013 multi-species reading: scale by the summed ion density. + self.nu_i_star = ( + ion_rate + * self.rmaj + * abs(self.q) + / ( + self.epsilon**1.5 + * np.sqrt(self.temperature[self.main_ion] / self.mass[self.main_ion]) + ) + / self.density[self.main_ion] + * sum(self.density[i] for i in self.ions) + ) + + self.pressure_electron = ( + self.density[self.electron] * self.temperature[self.electron] + ) + self.pressure_ion = sum( + self.density[i] * self.temperature[i] for i in self.ions + ) + self.dp = sum( + self.density[i] * self.temperature[i] * (self.dlntdr[i] + self.dlnndr[i]) + for i in range(n_species) + ) + + @property + def neo_bootstrap_current(self) -> float: + """NEO's own drift-kinetic , out.neo.transport column 2.""" + return float(self.transport[2]) + + def current_arguments(self, **overrides) -> dict: + """Keyword arguments for the two bootstrap-current entry points. + + NEO writes `+I/psi' * rho * p * (a/L_p)`, VAFT writes + `-I_psi * dp/dpsi`; `a/L_p` is minus the radial logarithmic gradient, so + the two agree with `I_psi = -(I/psi') * rho`. Both expressions are + dimensionally homogeneous, so passing NEO's normalised quantities + throughout is consistent. + """ + arguments = dict( + f_trap=self.f_trap, + nu_e_star=self.nu_e_star, + nu_i_star=self.nu_i_star, + Z_eff=self.z_eff, + I_psi=-self.i_over_psi_prime * self.rho_star, + p_e=self.pressure_electron, + p_i=self.pressure_ion, + dp_dpsi=self.dp, + dln_Te_dpsi=self.dlntdr[self.electron], + dln_Ti_dpsi=self.dlntdr[self.main_ion], + ) + arguments.update(overrides) + return arguments + + +@pytest.fixture(scope="module") +def runs() -> dict[str, _NeoRun]: + return {name: _NeoRun(path) for name, path in NEO_RUNS.items()} + + +@pytest.fixture(scope="module") +def reg18(runs) -> _NeoRun: + return runs["reg18"] + + +@pytest.fixture(scope="module") +def vest(runs) -> _NeoRun: + return runs["vest_48224"] + + +# -------------------------------------------------------------------------- +# Verification against NEO +# -------------------------------------------------------------------------- + + +def test_reg18_fixture_is_the_case_we_think_it_is(reg18): + """Guard the fixture: three species, a carbon impurity, low collisionality.""" + assert reg18.charge.size == 3 + assert sorted(reg18.charge) == [-1.0, 1.0, 6.0] + assert reg18.z_eff == pytest.approx(1.9109, abs=1e-4) + assert reg18.epsilon == pytest.approx(0.17264, abs=1e-5) + # Banana regime, which is where the trapped-particle physics is strongest + # and where the two fits are most nearly equal. + assert reg18.nu_e_star < 0.1 + assert reg18.nu_i_star < 0.1 + + +@pytest.mark.parametrize("case", sorted(NEO_RUNS)) +def test_sauter_reproduces_neo(runs, case): + """VAFT's Sauter 1999 result equals NEO's, to NEO's own output precision. + + `out.neo.theory` column 10 is `SjparB`, NEO's `compute_Sauter`. NEO writes + it in `e16.8`, so eight significant figures is all the file carries and + agreement cannot be asserted tighter than that. + + Both regimes are checked because the two fits differ mainly through the + trapped fraction, and reg18's 0.56 alone would not exercise the range VEST + puts them in. + """ + run = runs[case] + current = sauter_bootstrap_current(**run.current_arguments()) + assert current == pytest.approx(run.theory[10], rel=1e-7) + + +@pytest.mark.parametrize("case", sorted(NEO_RUNS)) +def test_redl_reproduces_neo(runs, case): + """VAFT's Redl 2021 result equals NEO's `compute_Sauter_mod`. + + The last column of `out.neo.theory` is `jpar_Smod`. + """ + run = runs[case] + current = redl_bootstrap_current(**run.current_arguments()) + assert current == pytest.approx(run.theory[-1], rel=1e-7) + + +def test_the_two_fixtures_really_are_different_regimes(reg18, vest): + """Guard the premise of parametrising over both runs.""" + assert reg18.f_trap == pytest.approx(0.563, abs=0.01) + assert vest.f_trap == pytest.approx(0.732, abs=0.01) + assert reg18.z_eff > 1.5 and vest.z_eff == pytest.approx(1.0, abs=1e-6) + + +def test_redl_is_closer_than_sauter_to_neo_on_the_vest_state(vest): + """The reason both formulations exist, measured on VEST's own kinetic state. + + At f_trap = 0.73 the 1999 fit is outside the range it was built on and the + 2021 refit is not. Neither matches the drift-kinetic solve -- they are + analytic approximations to it -- but Redl is several times closer, which is + what makes it the defensible default for a spherical tokamak. + + This is a physics-model comparison, not a solver check: a Sauter-NEO gap of + this size is a real property of the model, not a failure (issue #550). + """ + arguments = vest.current_arguments() + reference = vest.neo_bootstrap_current + sauter_error = abs(sauter_bootstrap_current(**arguments) / reference - 1.0) + redl_error = abs(redl_bootstrap_current(**arguments) / reference - 1.0) + assert sauter_error > 0.05, "Sauter should visibly overshoot NEO here" + assert redl_error < 0.03, "Redl should stay within a few percent" + assert redl_error < 0.5 * sauter_error + + +def test_sauter_alpha_matches_neos_poloidal_flow_coefficient(reg18): + """An independent check on alpha alone, not just on the assembled current. + + NEO writes `Sk = -alpha_S` as column 11, so this pins the ion-collisionality + branch of the fit without the pressure gradients being able to hide an + error in it. + """ + coefficients = sauter_bootstrap_coefficients( + reg18.f_trap, reg18.nu_e_star, reg18.nu_i_star, reg18.z_eff + ) + assert coefficients.alpha == pytest.approx(-reg18.theory[11], rel=1e-7) + + +def test_the_two_models_agree_more_closely_at_conventional_aspect_ratio(reg18, vest): + """The Sauter-Redl gap grows with the trapped fraction. + + Measured across the two stored runs rather than by moving one of them, so + the comparison is between two real equilibria. + """ + def gap(run): + arguments = run.current_arguments() + return abs( + redl_bootstrap_current(**arguments) + / sauter_bootstrap_current(**arguments) + - 1.0 + ) + + assert gap(reg18) < 0.02 + assert gap(vest) > 2.0 * gap(reg18) + + +def test_the_models_separate_at_spherical_tokamak_shape(reg18): + """At VEST's trapped fraction the two fits no longer agree. + + The 1999 fit is an extrapolation there and the 2021 refit is not, so the + growing gap is the documented reason `redl_*` exists (issue #550). + """ + arguments = reg18.current_arguments() + conventional = abs( + redl_bootstrap_current(**arguments) / sauter_bootstrap_current(**arguments) - 1.0 + ) + arguments["f_trap"] = trapped_particle_fraction(0.6) + spherical = abs( + redl_bootstrap_current(**arguments) / sauter_bootstrap_current(**arguments) - 1.0 + ) + assert spherical > 3.0 * conventional + + +# -------------------------------------------------------------------------- +# Trapped fraction +# -------------------------------------------------------------------------- + + +def test_trapped_fraction_vanishes_on_axis(): + assert trapped_particle_fraction(0.0) == pytest.approx(0.0) + + +def test_trapped_fraction_rises_with_inverse_aspect_ratio(): + epsilon = np.linspace(0.0, 0.95, 40) + assert np.all(np.diff(trapped_particle_fraction(epsilon)) > 0.0) + + +def test_trapped_fraction_at_vest_and_at_a_conventional_tokamak(): + """VEST traps far more of its distribution than a conventional device. + + At small inverse aspect ratio the formula should recover the textbook + `sqrt(2 eps)` estimate; at VEST's it is far outside that expansion, which is + the reason bootstrap current is a first-order concern for a spherical + tokamak. + """ + conventional = trapped_particle_fraction(0.1) + vest = trapped_particle_fraction(0.6) + assert conventional == pytest.approx(np.sqrt(2.0 * 0.1), rel=0.02) + assert vest == pytest.approx(0.906, abs=0.005) + assert vest > 2.0 * conventional + + +def test_trapped_fraction_is_within_a_percent_of_neos_shaped_value(reg18): + """The circular approximation against a real shaped equilibrium. + + NEO computes f_trap by integrating over the field strength on the surface + and writes it to out.neo.diagnostic_geo. reg18 is mildly shaped, so the + circular formula should be close but not equal -- and the gap is the size + of the error a caller accepts by using this instead of the equilibrium. + """ + circular = trapped_particle_fraction(reg18.epsilon) + assert circular == pytest.approx(reg18.f_trap, rel=0.01) + assert circular != reg18.f_trap + + +@pytest.mark.parametrize("bad", [-0.1, 1.0, 1.5, np.nan]) +def test_trapped_fraction_rejects_input_outside_its_domain(bad): + with pytest.raises(ValueError): + trapped_particle_fraction(bad) + + +# -------------------------------------------------------------------------- +# Coulomb logarithms and collisionality +# -------------------------------------------------------------------------- + + +def test_sauter_electron_coulomb_log_differs_from_the_nrl_one_by_its_constant(): + """The two conventions differ by exactly 31.3 - 30.9, and no more. + + Mixing them is the failure this pins: the collisionality fits here were + built on the Sauter constant (issue #353). + """ + from vaft.formula.equilibrium import coulomb_logarithm_from_n_T + + n_e, T_e = 5.0e19, 300.0 + difference = coulomb_logarithm_electron_sauter(n_e, T_e) - coulomb_logarithm_from_n_T( + n_e, T_e + ) + assert difference == pytest.approx(0.4, abs=1e-12) + + +def test_ion_coulomb_log_falls_with_the_cube_of_the_charge(): + single = coulomb_logarithm_ion_sauter(1.0e19, 100.0, 1.0) + carbon = coulomb_logarithm_ion_sauter(1.0e19, 100.0, 6.0) + assert single - carbon == pytest.approx(3.0 * np.log(6.0), rel=1e-12) + + +def test_electron_collisionality_scales_as_the_paper_says(): + base = dict(n_e=5.0e19, T_e=300.0, q=2.0, R=0.4, epsilon=0.1, Z_eff=2.0, + ln_Lambda_e=15.0) + reference = electron_collisionality_sauter(**base) + assert electron_collisionality_sauter(**{**base, "n_e": 1.0e20}) == pytest.approx( + 2.0 * reference, rel=1e-12 + ), "linear in density" + assert electron_collisionality_sauter(**{**base, "T_e": 600.0}) == pytest.approx( + reference / 4.0, rel=1e-12 + ), "inverse square in temperature" + assert electron_collisionality_sauter(**{**base, "epsilon": 0.4}) == pytest.approx( + reference / 8.0, rel=1e-12 + ), "epsilon to the minus three halves" + assert electron_collisionality_sauter(**{**base, "Z_eff": 4.0}) == pytest.approx( + 2.0 * reference, rel=1e-12 + ), "linear in Z_eff" + assert electron_collisionality_sauter(**{**base, "q": -2.0}) == pytest.approx( + reference, rel=1e-12 + ), "the magnitude of q is used, so the COCOS sign must not change nu_e*" + + +def test_ion_collisionality_scales_with_the_fourth_power_of_charge(): + base = dict(n_i=1.0e19, T_i=100.0, q=2.0, R=0.4, epsilon=0.3, Z=1.0, + ln_Lambda_ii=15.0) + reference = ion_collisionality_sauter(**base) + assert ion_collisionality_sauter(**{**base, "Z": 2.0}) == pytest.approx( + 16.0 * reference, rel=1e-12 + ) + + +def test_collisionality_defaults_to_the_sauter_coulomb_log(): + n_e, T_e = 5.0e19, 300.0 + explicit = electron_collisionality_sauter( + n_e, T_e, 2.0, 0.4, 0.3, 2.0, + ln_Lambda_e=coulomb_logarithm_electron_sauter(n_e, T_e), + ) + implicit = electron_collisionality_sauter(n_e, T_e, 2.0, 0.4, 0.3, 2.0) + assert implicit == pytest.approx(explicit, rel=1e-15) + + +# -------------------------------------------------------------------------- +# Conductivity +# -------------------------------------------------------------------------- + + +def test_neoclassical_conductivity_never_exceeds_spitzer(): + """Trapping can only remove current carriers, never add them.""" + spitzer = sauter_spitzer_conductivity(300.0, 2.0, 15.0) + trapped = np.linspace(0.0, 0.95, 20) + for collisionality in (0.0, 0.1, 1.0, 10.0): + for model in (sauter_neoclassical_conductivity, redl_neoclassical_conductivity): + values = model(spitzer, trapped, collisionality, 2.0) + assert np.all(values <= spitzer * (1.0 + 1e-12)) + assert np.all(values > 0.0) + + +def test_conductivity_reduces_to_spitzer_with_no_trapped_particles(): + spitzer = sauter_spitzer_conductivity(300.0, 2.0, 15.0) + assert sauter_neoclassical_conductivity(spitzer, 0.0, 0.5, 2.0) == pytest.approx( + spitzer + ) + assert redl_neoclassical_conductivity(spitzer, 0.0, 0.5, 2.0) == pytest.approx( + spitzer + ) + + +def test_collisions_restore_the_conductivity_trapping_removed(): + """As nu_e* rises the neoclassical correction weakens towards Spitzer.""" + spitzer = sauter_spitzer_conductivity(300.0, 2.0, 15.0) + collisionalities = np.array([0.0, 0.1, 1.0, 10.0, 100.0]) + ratios = sauter_neoclassical_conductivity(spitzer, 0.6, collisionalities, 2.0) / spitzer + assert np.all(np.diff(ratios) > 0.0) + assert ratios[-1] > 0.9 + + +def test_spitzer_conductivity_scales_with_temperature_to_the_three_halves(): + low = sauter_spitzer_conductivity(100.0, 1.0, 15.0) + high = sauter_spitzer_conductivity(400.0, 1.0, 15.0) + assert high / low == pytest.approx(8.0, rel=1e-12) + + +# -------------------------------------------------------------------------- +# Bootstrap coefficients and their limits +# -------------------------------------------------------------------------- + + +def test_bootstrap_coefficients_vanish_with_no_trapped_particles(): + """No trapped particles, no banana current: only alpha survives.""" + for model in (sauter_bootstrap_coefficients, redl_bootstrap_coefficients): + coefficients = model(0.0, 0.5, 0.5, 2.0) + assert coefficients.L31 == pytest.approx(0.0) + assert coefficients.L32 == pytest.approx(0.0) + assert coefficients.L34 == pytest.approx(0.0) + + +def test_sauter_alpha_reaches_its_banana_limit(): + """At nu_i* = 0 and f_t = 0, alpha is the paper's alpha_0 = -1.17.""" + coefficients = sauter_bootstrap_coefficients(0.0, 0.0, 0.0, 1.0) + assert coefficients.alpha == pytest.approx(-1.17, rel=1e-12) + + +def test_redl_alpha_reaches_its_own_banana_limit(): + coefficients = redl_bootstrap_coefficients(0.0, 0.0, 0.0, 1.0) + assert coefficients.alpha == pytest.approx(-0.62 / 0.53, rel=1e-12) + + +def test_alpha_is_negative_in_the_banana_regime(): + """The ion-temperature term opposes the density and electron terms.""" + for model in (sauter_bootstrap_coefficients, redl_bootstrap_coefficients): + assert model(0.5, 0.01, 0.01, 1.5).alpha < 0.0 + + +def test_collisions_suppress_the_bootstrap_coefficients(): + """L31 falls monotonically as the plasma leaves the banana regime.""" + collisionalities = np.array([0.0, 0.1, 1.0, 10.0, 100.0]) + for model in (sauter_bootstrap_coefficients, redl_bootstrap_coefficients): + l31 = np.asarray(model(0.5, collisionalities, collisionalities, 2.0).L31) + assert np.all(np.diff(l31) < 0.0) + + +def test_l31_grows_with_the_trapped_fraction(): + trapped = np.linspace(0.0, 0.9, 20) + for model in (sauter_bootstrap_coefficients, redl_bootstrap_coefficients): + l31 = np.asarray(model(trapped, 0.05, 0.05, 1.5).L31) + assert np.all(np.diff(l31) > 0.0) + + +def test_coefficients_are_a_named_tuple_in_a_fixed_order(): + coefficients = sauter_bootstrap_coefficients(0.5, 0.1, 0.1, 2.0) + assert isinstance(coefficients, BootstrapCoefficients) + assert tuple(coefficients) == ( + coefficients.L31, + coefficients.L32, + coefficients.L34, + coefficients.alpha, + ) + + +def test_redl_sets_l34_equal_to_l31(): + """Documented: Redl does not refit L34, and the field exists for symmetry.""" + coefficients = redl_bootstrap_coefficients(0.5, 0.1, 0.1, 2.0) + assert coefficients.L34 == coefficients.L31 + + +# -------------------------------------------------------------------------- +# Bootstrap current: sign, scaling and shape +# -------------------------------------------------------------------------- + + +def _current_state(**overrides) -> dict: + state = dict( + f_trap=0.6, nu_e_star=0.05, nu_i_star=0.05, Z_eff=1.5, + I_psi=0.2, p_e=800.0, p_i=400.0, + dp_dpsi=-4.0e3, dln_Te_dpsi=-2.0, dln_Ti_dpsi=-2.0, + ) + state.update(overrides) + return state + + +def test_a_falling_pressure_profile_drives_a_positive_bootstrap_current(): + """With I > 0 and pressure falling outward, the L31 term is positive. + + A sign flip here is the signature of a COCOS or Wb-per-radian mistake in the + caller, which is why the convention is asserted rather than left implicit. + """ + assert sauter_bootstrap_current(**_current_state()) > 0.0 + assert redl_bootstrap_current(**_current_state()) > 0.0 + + +def test_the_current_reverses_with_the_sign_of_the_flux_function(): + forward = sauter_bootstrap_current(**_current_state()) + reversed_field = sauter_bootstrap_current(**_current_state(I_psi=-0.2)) + assert reversed_field == pytest.approx(-forward, rel=1e-12) + + +def test_a_flat_plasma_drives_no_bootstrap_current(): + state = _current_state(dp_dpsi=0.0, dln_Te_dpsi=0.0, dln_Ti_dpsi=0.0) + assert sauter_bootstrap_current(**state) == pytest.approx(0.0) + assert redl_bootstrap_current(**state) == pytest.approx(0.0) + + +def test_the_current_is_linear_in_the_gradients(): + single = sauter_bootstrap_current(**_current_state()) + doubled = sauter_bootstrap_current( + **_current_state(dp_dpsi=-8.0e3, dln_Te_dpsi=-4.0, dln_Ti_dpsi=-4.0) + ) + assert doubled == pytest.approx(2.0 * single, rel=1e-12) + + +def test_the_ion_temperature_term_opposes_the_others(): + """alpha < 0 and L34 > 0, so an ion temperature gradient reduces the total.""" + without = sauter_bootstrap_current(**_current_state(dln_Ti_dpsi=0.0)) + with_gradient = sauter_bootstrap_current(**_current_state()) + assert with_gradient < without + + +# -------------------------------------------------------------------------- +# Array/scalar behaviour and input validation +# -------------------------------------------------------------------------- + + +def test_scalar_and_array_calls_agree_elementwise(): + trapped = np.array([0.2, 0.5, 0.7]) + vectorised = np.asarray( + sauter_bootstrap_coefficients(trapped, 0.05, 0.05, 1.8).L31 + ) + elementwise = [ + sauter_bootstrap_coefficients(value, 0.05, 0.05, 1.8).L31 for value in trapped + ] + np.testing.assert_allclose(vectorised, elementwise, rtol=1e-15) + + +def test_scalar_input_returns_a_python_float(): + assert isinstance(trapped_particle_fraction(0.3), float) + assert isinstance(sauter_bootstrap_coefficients(0.5, 0.1, 0.1, 2.0).L31, float) + + +def test_array_input_returns_an_array_of_the_same_shape(): + trapped = np.linspace(0.1, 0.8, 7) + result = np.asarray(sauter_bootstrap_current(**_current_state(f_trap=trapped))) + assert result.shape == trapped.shape + + +@pytest.mark.parametrize( + ("model", "kwargs"), + [ + (sauter_bootstrap_coefficients, {"f_trap": 1.5}), + (sauter_bootstrap_coefficients, {"f_trap": -0.1}), + (sauter_bootstrap_coefficients, {"nu_e_star": -1.0}), + (sauter_bootstrap_coefficients, {"nu_i_star": -1.0}), + (sauter_bootstrap_coefficients, {"Z_eff": 0.0}), + (redl_bootstrap_coefficients, {"f_trap": np.nan}), + (redl_bootstrap_coefficients, {"Z_eff": 0.5}), + ], +) +def test_coefficients_reject_input_outside_their_domain(model, kwargs): + arguments = {"f_trap": 0.5, "nu_e_star": 0.1, "nu_i_star": 0.1, "Z_eff": 2.0} + arguments.update(kwargs) + with pytest.raises(ValueError): + model(**arguments) + + +def test_redl_refuses_a_charge_below_one(): + """sqrt(Z - 1) appears in the refit, so Z_eff < 1 is not merely unphysical.""" + with pytest.raises(ValueError, match="Z_eff must be >= 1"): + redl_neoclassical_conductivity(1.0e6, 0.5, 0.1, 0.9) + + +@pytest.mark.parametrize( + "function", + [coulomb_logarithm_electron_sauter, electron_collisionality_sauter], +) +def test_non_positive_density_is_rejected(function): + with pytest.raises(ValueError): + if function is coulomb_logarithm_electron_sauter: + function(0.0, 100.0) + else: + function(0.0, 100.0, 2.0, 0.4, 0.3, 2.0) + + +def test_non_finite_gradients_are_rejected(): + with pytest.raises(ValueError, match="dp_dpsi must be finite"): + sauter_bootstrap_current(**_current_state(dp_dpsi=np.nan)) + + +def test_the_spitzer_coefficient_agrees_with_the_packages_own_at_z_one(): + """An independent check on 1.9012e4, against a formula from a different source. + + `vaft.formula.equilibrium.spitzer_resistivity_from_T_e_Z_eff_ln_Lambda` is + the NRL form with a linear Z dependence. At Z_eff = 1 the two charge + treatments coincide, so the prefactors must agree to about a percent; a + typo in either would be far larger than that. + """ + from vaft.formula.equilibrium import spitzer_resistivity_from_T_e_Z_eff_ln_Lambda + + T_e, ln_Lambda = 1.0e3, 17.0 + mine = sauter_spitzer_conductivity(T_e, 1.0, ln_Lambda) + theirs = 1.0 / spitzer_resistivity_from_T_e_Z_eff_ln_Lambda(T_e, 1.0, ln_Lambda) + assert mine == pytest.approx(theirs, rel=0.02) + + +def test_the_two_spitzer_conventions_diverge_at_higher_charge(): + """The documented reason not to mix them: N_Z is not a linear Z dependence. + + At Z_eff = 3 the two differ by tens of percent, which is why the + neoclassical corrections here must be paired with this module's Spitzer + reference rather than the NRL one. + """ + from vaft.formula.equilibrium import spitzer_resistivity_from_T_e_Z_eff_ln_Lambda + + T_e, ln_Lambda, Z_eff = 1.0e3, 17.0, 3.0 + mine = sauter_spitzer_conductivity(T_e, Z_eff, ln_Lambda) + theirs = 1.0 / spitzer_resistivity_from_T_e_Z_eff_ln_Lambda(T_e, Z_eff, ln_Lambda) + assert 1.15 < mine / theirs < 1.45 diff --git a/test/test_gacode_adapter.py b/test/test_gacode_adapter.py new file mode 100644 index 00000000..3d274267 --- /dev/null +++ b/test/test_gacode_adapter.py @@ -0,0 +1,587 @@ +"""The GACODE runtime and the NEO adapter (issue #550). + +Everything here runs without GACODE installed. Executable resolution is checked +against launchable stubs written by `test/external_code_stubs.py`, which travel +exactly the path a real `neo` does, and the parsers are checked against stored +output from two real NEO runs in different regimes. + +The single test that needs a real installation is gated on `$GACODEHOME`, in the +style `test_nubeam_adapter.py` established: the variable is read once at import, +because the autouse fixture below removes it before any test body runs. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import numpy as np +import pytest + +from external_code_stubs import write_launchable_stub, write_unlaunchable_file + +from vaft.code._executables import ExecutableNotLaunchable +from vaft.code.gacode import ( + GACODE_HOME_ENV, + GACODEConfig, + available_platforms, + find_gacode_executable, + gacode_environment, + gacode_home, + gacode_platform, + launcher_relative_path, + require_gacode_executable, + run_gacode, +) +from vaft.code.gacode._profiles import GACODEProfile +from vaft.code.gacode.neo import ( + NEOConfig, + NEOExecutionError, + NeoOutputs, + collect_neo_outputs, + neo_parameters, + prepare_neo_case, + run_neo, + write_input_neo, +) + +FIXTURES = Path(__file__).parent / "data" / "gacode" +REG18 = FIXTURES / "neo_reg18" +VEST = FIXTURES / "neo_vest_48224" + +#: Read at import: the autouse fixture removes it before any test body runs, and +#: the skipif below is evaluated here too, so this is the value it consulted. +INSTALLED_GACODE_HOME = os.environ.get(GACODE_HOME_ENV) or os.environ.get("GACODE_ROOT") +INSTALLED_GACODE_PLATFORM = os.environ.get("GACODE_PLATFORM") + +_ENVIRONMENT = (GACODE_HOME_ENV, "GACODE_ROOT", "GACODE_PLATFORM") + + +@pytest.fixture(autouse=True) +def clear_gacode_environment(monkeypatch): + for name in _ENVIRONMENT: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def installation(tmp_path) -> Path: + """A minimal tree with the shape the resolver documents.""" + root = tmp_path / "gacode" + write_launchable_stub(root / launcher_relative_path("neo")) + (root / "platform" / "build").mkdir(parents=True, exist_ok=True) + (root / "platform" / "build" / "make.inc.GFORTRAN_OSX_BREW").write_text("") + (root / "platform" / "build" / "make.inc.CI_CPU").write_text("") + return root + + +# -------------------------------------------------------------------------- +# Importability +# -------------------------------------------------------------------------- + + +def test_importing_the_package_does_not_need_gacode(): + """The whole point of resolving lazily: `from vaft.code import *` must work.""" + import vaft.code + import vaft.code.gacode + import vaft.code.gacode.neo # noqa: F401 + + assert "gacode" in vaft.code.__all__ + assert gacode_home(GACODEConfig()) is None + + +def test_the_suite_layout_is_per_code_not_a_shared_bin(): + """GACODE gives every member its own bin, unlike every other adapter here.""" + assert launcher_relative_path("neo") == Path("neo") / "bin" / "neo" + assert launcher_relative_path("TGLF") == Path("tglf") / "bin" / "tglf" + + +def test_a_name_outside_the_suite_is_refused(): + with pytest.raises(ValueError, match="not a GACODE suite member"): + launcher_relative_path("tokamaker") + + +# -------------------------------------------------------------------------- +# Executable and platform resolution +# -------------------------------------------------------------------------- + + +def test_the_canonical_layout_resolves(monkeypatch, installation): + monkeypatch.setenv(GACODE_HOME_ENV, str(installation)) + resolved = find_gacode_executable(GACODEConfig(), "neo") + assert resolved == installation / "neo" / "bin" / "neo" + + +def test_gacode_root_is_accepted_as_a_compatibility_fallback(monkeypatch, installation): + """VAFT adds $GACODEHOME; it does not take $GACODE_ROOT away.""" + monkeypatch.setenv("GACODE_ROOT", str(installation)) + assert gacode_home(GACODEConfig()) == installation + assert find_gacode_executable(GACODEConfig(), "neo") is not None + + +def test_the_config_wins_over_the_environment(monkeypatch, installation, tmp_path): + monkeypatch.setenv(GACODE_HOME_ENV, str(tmp_path / "elsewhere")) + assert gacode_home(GACODEConfig(home=str(installation))) == installation + + +def test_an_unconfigured_installation_is_not_an_error_until_something_runs(): + assert find_gacode_executable(GACODEConfig(), "neo") is None + + +def test_requiring_an_unconfigured_installation_says_what_to_set(): + with pytest.raises(FileNotFoundError) as error: + require_gacode_executable(GACODEConfig(), "neo") + message = str(error.value) + assert GACODE_HOME_ENV in message + assert "neo/bin/neo" in message + assert "$GACODE_ROOT" in message, "the compatibility variable must be named" + + +def test_a_configured_root_with_no_executable_names_the_expected_path(tmp_path): + with pytest.raises(FileNotFoundError) as error: + find_gacode_executable(GACODEConfig(home=str(tmp_path / "unbuilt")), "neo") + assert str(tmp_path / "unbuilt" / "neo" / "bin" / "neo") in str(error.value) + assert "Compile or install" in str(error.value) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits") +def test_a_present_but_unrunnable_launcher_is_a_permission_error(tmp_path): + root = tmp_path / "gacode" + write_unlaunchable_file(root / "neo" / "bin" / "neo") + with pytest.raises(PermissionError): + find_gacode_executable(GACODEConfig(home=str(root)), "neo") + + +def test_an_unset_platform_lists_what_the_installation_provides(installation): + with pytest.raises(ValueError) as error: + gacode_platform(GACODEConfig(home=str(installation))) + message = str(error.value) + assert "GACODE_PLATFORM" in message + assert "GFORTRAN_OSX_BREW" in message and "CI_CPU" in message + + +def test_a_platform_the_installation_does_not_have_is_refused(installation): + config = GACODEConfig(home=str(installation), platform="SUMMIT") + with pytest.raises(ValueError, match="is not built in this installation"): + gacode_platform(config) + + +def test_the_platform_comes_from_the_environment_when_the_config_omits_it( + monkeypatch, installation +): + monkeypatch.setenv("GACODE_PLATFORM", "CI_CPU") + assert gacode_platform(GACODEConfig(home=str(installation))) == "CI_CPU" + + +def test_available_platforms_reads_the_build_directory(installation): + assert available_platforms(installation) == ("CI_CPU", "GFORTRAN_OSX_BREW") + + +# -------------------------------------------------------------------------- +# The subprocess environment +# -------------------------------------------------------------------------- + + +def test_the_environment_sets_gacodes_own_variables(installation): + config = GACODEConfig(home=str(installation), platform="CI_CPU") + environment = gacode_environment(config, "neo") + assert environment["GACODE_ROOT"] == str(installation) + assert environment[GACODE_HOME_ENV] == str(installation) + assert environment["GACODE_PLATFORM"] == "CI_CPU" + + +def test_pygacode_is_on_the_pythonpath(installation): + """Without it the launcher's parse step fails and NEO blames input.neo.gen.""" + config = GACODEConfig(home=str(installation), platform="CI_CPU") + entries = gacode_environment(config, "neo")["PYTHONPATH"].split(os.pathsep) + assert str(installation / "f2py" / "pygacode") in entries + + +def test_path_and_pythonpath_are_prefixed_not_replaced(monkeypatch, installation): + monkeypatch.setenv("PATH", "/sentinel/bin") + monkeypatch.setenv("PYTHONPATH", "/sentinel/lib") + config = GACODEConfig(home=str(installation), platform="CI_CPU") + environment = gacode_environment(config, "neo") + assert environment["PATH"].endswith("/sentinel/bin") + assert environment["PYTHONPATH"].endswith("/sentinel/lib") + assert str(installation / "shared" / "bin") in environment["PATH"] + + +def test_config_env_has_the_last_word(installation): + config = GACODEConfig( + home=str(installation), platform="CI_CPU", env={"GACODE_PLATFORM": "OVERRIDE"} + ) + assert gacode_environment(config, "neo")["GACODE_PLATFORM"] == "OVERRIDE" + + +def test_a_launcher_the_system_refuses_to_start_is_reported_as_such(tmp_path): + root = tmp_path / "gacode" + (root / "platform" / "build").mkdir(parents=True) + (root / "platform" / "build" / "make.inc.CI_CPU").write_text("") + missing = root / "neo" / "bin" / "neo" + missing.parent.mkdir(parents=True) + with pytest.raises(ExecutableNotLaunchable): + run_gacode( + missing, + ["-e", "case"], + cwd=tmp_path, + log_path=tmp_path / "neo.log", + config=GACODEConfig(home=str(root), platform="CI_CPU"), + ) + + +def test_a_run_captures_its_log_and_returns_the_status(tmp_path, installation): + stub = write_launchable_stub(installation / "neo" / "bin" / "neo", exit_code=3) + config = GACODEConfig(home=str(installation), platform="CI_CPU") + returncode, log = run_gacode( + stub, [], cwd=tmp_path, log_path=tmp_path / "neo.log", config=config + ) + assert returncode == 3 + assert log.is_file() + + +# -------------------------------------------------------------------------- +# input.neo +# -------------------------------------------------------------------------- + + +def _profile(n_ion: int = 1, *, kappa: bool = True) -> GACODEProfile: + rho = np.linspace(0.0, 1.0, 8) + ones = np.ones((n_ion, rho.size)) + return GACODEProfile( + rho=rho, + z=np.arange(1, n_ion + 1, dtype=float), + mass=np.full(n_ion, 2.0), + name=tuple(f"i{i}" for i in range(n_ion)), + rmin=np.linspace(0.0, 0.3, rho.size), + rmaj=np.full(rho.size, 0.4), + polflux=np.linspace(0.0, 0.05, rho.size), + q=np.linspace(1.0, 3.0, rho.size), + ne=np.linspace(1.0, 0.2, rho.size), + te=np.linspace(1.0, 0.1, rho.size), + ni=ones, + ti=ones, + torfluxa=0.02, + rcentr=0.4, + bcentr=0.15, + current=0.1, + kappa=np.full(rho.size, 1.5) if kappa else None, + ) + + +def test_the_species_count_includes_electrons(): + assert neo_parameters(NEOConfig(), _profile(n_ion=2))["N_SPECIES"] == 3 + + +def test_every_setting_is_written_rather_than_left_to_neos_default(tmp_path): + """A file that omits a setting cannot later be told from a deliberate choice.""" + parameters = neo_parameters(NEOConfig(), _profile()) + text = write_input_neo(parameters, tmp_path / "input.neo").read_text() + for key in ("N_ENERGY", "N_XI", "N_THETA", "COLLISION_MODEL", "PROFILE_MODEL", + "ROTATION_MODEL", "N_SPECIES", "IPCCW", "BTCCW"): + assert f"{key}=" in text + + +def test_extra_parameters_are_written_verbatim(): + config = NEOConfig(extra_parameters={"threed_model": 1}) + assert neo_parameters(config, _profile())["THREED_MODEL"] == 1 + + +def test_input_neo_generation_is_deterministic(tmp_path): + first = write_input_neo(neo_parameters(NEOConfig(), _profile()), tmp_path / "a") + second = write_input_neo(neo_parameters(NEOConfig(), _profile()), tmp_path / "b") + assert first.read_text() == second.read_text() + + +def test_a_species_count_that_forgets_the_electrons_is_refused(): + with pytest.raises(ValueError, match="counts electrons too"): + NEOConfig(n_species=1) + + +@pytest.mark.parametrize("radius", [0.0, 1.0, 1.5, -0.2]) +def test_a_surface_that_is_not_one_is_refused(radius): + with pytest.raises(ValueError, match="rmin_over_a must lie in"): + NEOConfig(rmin_over_a=radius) + + +def test_staging_writes_both_input_files(tmp_path): + staged = prepare_neo_case(_profile(), tmp_path / "case") + assert staged.input_gacode.is_file() and staged.input_neo.is_file() + assert set(staged.files) == {staged.input_gacode, staged.input_neo} + assert staged.provenance["n_ion"] == 1 + + +def test_staging_refuses_a_profile_that_cannot_feed_profile_model_2(tmp_path): + """PROFILE_MODEL=2 reads input.gacode, so what it needs must be there.""" + incomplete = GACODEProfile(rho=np.linspace(0, 1, 5), z=np.array([1.0])) + with pytest.raises(ValueError, match="missing"): + prepare_neo_case(incomplete, tmp_path / "case") + + +# -------------------------------------------------------------------------- +# Parsing stored runs +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("directory", [REG18, VEST], ids=["reg18", "vest_48224"]) +def test_a_stored_run_parses(directory): + native = collect_neo_outputs(directory) + assert native is not None + assert native.grid is not None + assert native.precision is not None + assert native.version["platform"] == "GFORTRAN_OSX_BREW" + + +def test_the_theory_layout_holds_for_two_and_three_species(): + """The column layout is neo_theory.f90's, not pygacode's stale one. + + pygacode reads the per-species block three-wide; the writer emits two values + per species and then two trailing scalars. Checking both species counts is + what separates the two readings -- they differ by `n_species` columns. + """ + for directory, species in ((REG18, 3), (VEST, 2)): + native = collect_neo_outputs(directory) + assert native.n_species == species + assert np.shape(native.theory["hirshman_sigmar_particle_flux"]) == (species, 1) + assert native.theory["redl_bootstrap_current"].shape == (1,) + + +def test_radial_and_species_dimensions_survive_parsing(): + native = collect_neo_outputs(REG18) + assert np.shape(native.transport["energy_flux"]) == (3, 1) + assert np.shape(native.equilibrium["density"]) == (3, 1) + assert native.grid.theta.size == native.grid.n_theta + + +def test_the_normalisation_and_coordinate_bridges_are_kept(): + """These two files are what make a normalised result mean anything. + + `expnorm` carries the SI scales, `exprhon` the map from NEO's r/a back to + rho_tor_norm and psi_norm, and so back into IMAS. + """ + native = collect_neo_outputs(REG18) + assert native.normalisation.a_meters[0] > 0.0 + assert native.normalisation.b_unit[0] > 0.0 + assert 0.0 < native.coordinates["rho_tor_norm"][0] < 1.0 + assert 0.0 < native.coordinates["psi_norm"][0] < 1.0 + + +def test_an_unwritten_product_is_absent_not_zero(): + """The VEST fixture stores fewer products, and they read back as None.""" + native = collect_neo_outputs(VEST) + assert "rotation" in native.missing() + assert native.rotation is None + # A product that *was* written and happens to be small stays a number. + assert native.transport["potential_squared"] is not None + + +def test_a_directory_with_no_neo_output_reads_as_nothing(tmp_path): + assert collect_neo_outputs(tmp_path) is None + assert collect_neo_outputs(tmp_path / "absent") is None + + +def test_describe_names_the_normalisation(): + native = collect_neo_outputs(REG18) + assert "n_0 v_t0" in native.describe("particle_flux") + with pytest.raises(KeyError, match="not a NEO transport quantity"): + native.describe("nonsense") + + +def test_the_native_result_round_trips_through_json(tmp_path): + native = collect_neo_outputs(REG18) + path = native.write_json(tmp_path / "native.json") + reloaded = NeoOutputs.read_json(path) + np.testing.assert_allclose(reloaded.bootstrap_current, native.bootstrap_current) + np.testing.assert_allclose(reloaded.grid.theta, native.grid.theta) + np.testing.assert_allclose( + reloaded.theory["sauter_bootstrap_current"], + native.theory["sauter_bootstrap_current"], + ) + assert reloaded.version == native.version + assert reloaded.missing() == native.missing() + + +def test_a_payload_from_a_newer_schema_is_refused(): + payload = collect_neo_outputs(REG18).to_dict() + payload["schema_version"] = 99 + with pytest.raises(ValueError, match="schema version 99"): + NeoOutputs.from_dict(payload) + + +def test_the_stored_payload_names_its_schema(): + payload = collect_neo_outputs(REG18).to_dict() + assert payload["schema"] == "vaft.code.gacode.neo.NeoOutputs" + + +# -------------------------------------------------------------------------- +# Running +# -------------------------------------------------------------------------- + + +def test_a_run_that_produces_nothing_fails_even_with_a_zero_exit(tmp_path, installation): + """NEO can exit cleanly having written nothing usable, so status is not enough.""" + write_launchable_stub(installation / "neo" / "bin" / "neo", exit_code=0) + config = NEOConfig(home=str(installation), platform="CI_CPU") + staged = prepare_neo_case(_profile(), tmp_path / "case", config) + with pytest.raises(NEOExecutionError, match="wrote no readable output"): + run_neo(staged, config) + + +def test_a_failed_run_is_returned_rather_than_raised_when_asked(tmp_path, installation): + write_launchable_stub(installation / "neo" / "bin" / "neo", exit_code=2) + config = NEOConfig(home=str(installation), platform="CI_CPU") + staged = prepare_neo_case(_profile(), tmp_path / "case", config) + result = run_neo(staged, config, check=False) + assert result.returncode == 2 and not result.ok + assert result.logs and result.logs[0].is_file() + + +def test_the_run_records_what_produced_it(tmp_path, installation): + write_launchable_stub(installation / "neo" / "bin" / "neo", exit_code=1) + config = NEOConfig(home=str(installation), platform="CI_CPU") + staged = prepare_neo_case(_profile(), tmp_path / "case", config) + result = run_neo(staged, config, check=False) + assert result.provenance["platform"] == "CI_CPU" + assert result.provenance["parameters"]["N_SPECIES"] == 2 + assert Path(result.provenance["executable"]).name.startswith("neo") + + +@pytest.mark.skipif( + not INSTALLED_GACODE_HOME, reason="NEO integration test requires $GACODEHOME" +) +def test_an_installed_neo_reproduces_the_reg18_regression_case(tmp_path): + """The shipped reg18 case, run through the VAFT adapter, end to end. + + `out.neo.prec` is what GACODE's own `neo -rc` compares, so reproducing it is + reproducing the regression. + """ + from vaft.code.gacode._input_gacode import read_input_gacode + from vaft.code.gacode.neo import run_neo_case + + profile = read_input_gacode(REG18 / "input.gacode") + config = NEOConfig( + home=INSTALLED_GACODE_HOME, + platform=INSTALLED_GACODE_PLATFORM, + n_species=3, + rotation_model=2, + ) + result = run_neo_case(profile, tmp_path / "reg18", config) + assert result.ok + expected = float((REG18 / "out.neo.prec").read_text().split()[0]) + assert result.outputs_native.precision == pytest.approx(expected, rel=1e-7) + + +@pytest.mark.parametrize( + ("filename", "message"), + [ + ("out.neo.transport", "out.neo.transport has"), + ("out.neo.equil", "out.neo.equil has"), + ("out.neo.theory", "columns but 3 species imply"), + ], +) +def test_a_table_of_the_wrong_width_is_refused_not_strided_over(tmp_path, filename, message): + """A stride over a mis-shaped table mis-assigns species instead of failing. + + Silently attributing one species' flux to another is worse than a parse + error, so the width is checked against the species count first. + """ + import shutil + + case = tmp_path / "case" + shutil.copytree(REG18, case) + values = (case / filename).read_text().split() + (case / filename).write_text(" ".join(values[:-1]) + "\n") + with pytest.raises(ValueError, match=message): + collect_neo_outputs(case) + + +# -------------------------------------------------------------------------- +# Review findings: a run is not successful because files exist +# -------------------------------------------------------------------------- + + +def _copy_run(tmp_path, source=REG18) -> Path: + import shutil + + case = tmp_path / "case" + shutil.copytree(source, case) + return case + + +def test_an_error_logged_to_out_neo_run_is_not_a_solve(tmp_path): + """NEO exits zero after rejecting its input; the log is the only signal.""" + case = _copy_run(tmp_path) + (case / "out.neo.run").write_text(" ERROR: (NEO) n_theta must be odd\n") + native = collect_neo_outputs(case) + assert native.errors == ("ERROR: (NEO) n_theta must be odd",) + assert not native.solved + + +def test_a_non_finite_current_is_not_a_solve(tmp_path): + """A degenerate geometry produces NaN with nothing logged, and must not pass.""" + case = _copy_run(tmp_path) + values = (case / "out.neo.transport").read_text().split() + values[2] = "NaN" + (case / "out.neo.transport").write_text(" ".join(values) + "\n") + native = collect_neo_outputs(case) + assert native.errors == () + assert not native.solved + + +def test_a_stored_good_run_counts_as_solved(): + assert collect_neo_outputs(REG18).solved + assert collect_neo_outputs(VEST).solved + + +def test_a_rerun_does_not_inherit_the_previous_runs_outputs(tmp_path, installation): + """Earlier out.neo.* in the case directory are cleared before launching. + + Without that, a rerun that writes nothing is parsed from the files the + previous run left, and reported as its own result. + """ + import shutil + + config = NEOConfig(home=str(installation), platform="CI_CPU") + staged = prepare_neo_case(_profile(), tmp_path / "case", config) + for product in REG18.glob("out.neo.*"): + shutil.copy(product, staged.workdir / product.name) + write_launchable_stub(installation / "neo" / "bin" / "neo", exit_code=0) + + with pytest.raises(NEOExecutionError): + run_neo(staged, config) + assert not list(staged.workdir.glob("out.neo.transport")) + + +def test_the_config_refuses_what_neo_would_reject(): + """neo_check.f90's own limits, named here instead of in out.neo.run.""" + with pytest.raises(ValueError, match="n_theta must be odd"): + NEOConfig(n_theta=16) + with pytest.raises(ValueError, match="at most 6 species"): + NEOConfig(n_species=7) + + +def test_staging_refuses_a_profile_without_elongation(tmp_path): + """expro reads an absent kappa as zero, which collapses every surface.""" + profile = _profile(kappa=False) + with pytest.raises(ValueError, match="kappa"): + prepare_neo_case(profile, tmp_path / "case") + + +@pytest.mark.skipif( + not INSTALLED_GACODE_HOME, reason="NEO integration test requires $GACODEHOME" +) +def test_an_installed_neo_rejection_raises(tmp_path): + """The real launcher path: NEO rejects the case, exits zero, and we notice.""" + from vaft.code.gacode._input_gacode import read_input_gacode + from vaft.code.gacode.neo import run_neo_case + + config = NEOConfig( + home=INSTALLED_GACODE_HOME, + platform=INSTALLED_GACODE_PLATFORM, + n_species=3, + rotation_model=2, + # Through extra_parameters, so the config's own check does not catch it. + extra_parameters={"N_THETA": 16}, + ) + profile = read_input_gacode(REG18 / "input.gacode") + with pytest.raises(NEOExecutionError, match="n_theta must be odd"): + run_neo_case(profile, tmp_path / "reg18", config) diff --git a/test/test_gacode_input.py b/test/test_gacode_input.py new file mode 100644 index 00000000..231c17f5 --- /dev/null +++ b/test/test_gacode_input.py @@ -0,0 +1,538 @@ +"""input.gacode read/write, and the ODS projection that feeds it (issue #550). + +The fixtures under `test/data/gacode/neo_reg18/` are a real GACODE artifact: +`input.gacode` is the file shipped with NEO's reg18 regression case, and the +`out.neo.*` files are a run of it that reproduced the shipped `out.neo.prec` +exactly. So "VAFT reads and rewrites this file without changing it" is a +statement about the real format, not about a fixture VAFT invented. + +None of this needs GACODE installed. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from vaft.code.gacode._input_gacode import ( + HEADER_KEYS, + PROFILE_TAGS, + read_input_gacode, + write_input_gacode, +) +from vaft.code.gacode._profiles import GACODEProfile +from vaft.code.gacode.inputs import ( + ProfileConversionError, + prepare_gacode_inputs, + prepare_gacode_profile, +) + +REG18 = Path(__file__).parent / "data" / "gacode" / "neo_reg18" / "input.gacode" + +#: `expro` relabelled these two between the release that wrote reg18's file and +#: the current source. The reader keys on the tag and ignores the unit; the +#: writer emits the current one, so a round trip changes exactly these lines. +RELABELLED_TAGS = ("qpar_beam", "qpar_wall", "qmom") + +SAMPLE = None +try: # pragma: no cover - depends on whether the repository sample is present + from vaft.data.resources import data_path + + _candidate = Path(data_path("kineticEfit/ods_48224_300ms.json")) + SAMPLE = _candidate if _candidate.exists() else None +except Exception: + SAMPLE = None + +requires_sample = pytest.mark.skipif( + SAMPLE is None, reason="the packaged 48224 kinetic sample is a repository-only asset" +) + + +@pytest.fixture(scope="module") +def reg18_profile() -> GACODEProfile: + return read_input_gacode(REG18) + + +@pytest.fixture(scope="module") +def ods_48224(): + from omas import load_omas_json + + # consistency_check=False: the committed sample carries a handful of leaves + # that the installed IMAS version no longer recognises (`profiles_1d.centroid` + # among them), which is a property of the sample, not of this conversion. + return load_omas_json(str(SAMPLE), consistency_check=False) + + +# -------------------------------------------------------------------------- +# Reading a real input.gacode +# -------------------------------------------------------------------------- + + +def test_reads_the_shipped_reg18_file(reg18_profile): + assert reg18_profile.n_exp == 51 + assert reg18_profile.n_ion == 2 + assert reg18_profile.shot == 141459 + assert reg18_profile.time == 3890 + assert reg18_profile.name == ("D", "C") + assert reg18_profile.type == ("[therm]", "[therm]") + np.testing.assert_allclose(reg18_profile.z, [1.0, 6.0]) + np.testing.assert_allclose(reg18_profile.mass, [2.0, 12.0]) + + +def test_per_ion_sections_are_shaped_species_by_radius(reg18_profile): + assert reg18_profile.ni.shape == (2, 51) + assert reg18_profile.ti.shape == (2, 51) + # The carbon density is an order of magnitude below the deuterium one, so a + # transposed read would be obvious here. + assert reg18_profile.ni[0, 0] > 10.0 * reg18_profile.ni[1, 0] + + +def test_the_radial_coordinate_runs_from_zero_to_one(reg18_profile): + assert reg18_profile.rho[0] == pytest.approx(0.0) + assert reg18_profile.rho[-1] == pytest.approx(1.0, abs=1e-6) + + +def test_scalars_and_header_are_read(reg18_profile): + assert reg18_profile.torfluxa == pytest.approx(5.6625370e-01) + assert reg18_profile.rcentr == pytest.approx(1.6955000e00) + assert reg18_profile.bcentr == pytest.approx(1.8316507e00) + assert reg18_profile.current == pytest.approx(-1.2579084e00) + assert reg18_profile.header["statefile"] == "iterdb141459.03890" + + +def test_shape_harmonics_and_sources_are_kept_apart(reg18_profile): + assert set(reg18_profile.shape) == { + "shape_cos0", "shape_cos1", "shape_cos2", "shape_cos3", "shape_sin3", + } + assert "qohme" in reg18_profile.sources + assert reg18_profile.extra == {} + + +def test_reg18_has_everything_neo_needs(reg18_profile): + assert reg18_profile.check_neo_requirements() == () + + +# -------------------------------------------------------------------------- +# Writing +# -------------------------------------------------------------------------- + + +def _differing_sections(left: Path, right: Path) -> set[str]: + """Tags whose section text differs between two input.gacode files.""" + + def sections(path: Path) -> dict[str, list[str]]: + found: dict[str, list[str]] = {} + current = None + for line in path.read_text().splitlines(): + if line.startswith("#") and ":" not in line and line.strip() != "#": + current = line[1:].split("|")[0].strip() + found[current] = [line] + elif current is not None: + found[current].append(line) + return found + + first, second = sections(left), sections(right) + return { + tag + for tag in set(first) | set(second) + if first.get(tag) != second.get(tag) + } + + +def test_the_round_trip_changes_only_two_stale_unit_labels(reg18_profile, tmp_path): + """VAFT rewrites GACODE's own file essentially byte for byte. + + The two tags that do differ differ only in the unit string in their header, + which `expro` itself renamed; the numbers are untouched. + """ + target = write_input_gacode(reg18_profile, tmp_path / "input.gacode") + original = REG18.read_text().splitlines() + rewritten = target.read_text().splitlines() + assert len(original) == len(rewritten) + + differing = [ + (a, b) for a, b in zip(original, rewritten) if a != b + ] + assert len(differing) == len(RELABELLED_TAGS) + for before, after in differing: + assert any(tag in before and tag in after for tag in RELABELLED_TAGS) + assert before.split("|")[0] == after.split("|")[0] + + +def test_writing_is_idempotent(reg18_profile, tmp_path): + first = write_input_gacode(reg18_profile, tmp_path / "a.gacode") + second = write_input_gacode(read_input_gacode(first), tmp_path / "b.gacode") + assert first.read_text() == second.read_text() + + +def test_every_numeric_field_survives_the_round_trip(reg18_profile, tmp_path): + target = write_input_gacode(reg18_profile, tmp_path / "input.gacode") + reloaded = read_input_gacode(target) + def lookup(profile, tag): + # Explicit, not an `or` chain: these are arrays, and truthiness on an + # array is ambiguous. + if tag in profile.shape: + return profile.shape[tag] + if tag in profile.sources: + return profile.sources[tag] + return getattr(profile, tag, None) + + for tag, _unit, _per_ion in PROFILE_TAGS: + before = lookup(reg18_profile, tag) + after = lookup(reloaded, tag) + if before is None: + assert after is None, f"{tag} appeared from nowhere" + continue + np.testing.assert_allclose(np.asarray(after), np.asarray(before), err_msg=tag) + + +def test_an_identically_zero_profile_is_omitted_not_written_as_zeros(tmp_path): + """`expro_writev` skips a zero vector, so writing zeros would be a lie. + + A tag's absence means "not set, or identically zero"; writing an explicit + zero array would claim a measurement that was never made. + """ + profile = GACODEProfile( + rho=np.linspace(0.0, 1.0, 5), + z=np.array([1.0]), + mass=np.array([2.0]), + ne=np.ones(5), + te=np.ones(5), + jbs=np.zeros(5), + ) + text = write_input_gacode(profile, tmp_path / "input.gacode").read_text() + assert "# ne | 10^19/m^3" in text + assert "jbs" not in text + + +def test_the_header_keeps_expros_fixed_six_line_order(tmp_path): + profile = GACODEProfile( + rho=np.linspace(0.0, 1.0, 5), z=np.array([1.0]), mass=np.array([2.0]), + ne=np.ones(5), te=np.ones(5), + ) + lines = write_input_gacode(profile, tmp_path / "input.gacode").read_text().splitlines() + for index, key in enumerate(HEADER_KEYS): + assert f"*{key}" in lines[index] + assert lines[len(HEADER_KEYS)].strip() == "#" + + +# -------------------------------------------------------------------------- +# Malformed input +# -------------------------------------------------------------------------- + + +def test_a_file_without_rho_is_refused(tmp_path): + path = tmp_path / "input.gacode" + path.write_text("#\n# nexp\n3\n# z\n 1.0000000E+00\n") + with pytest.raises(ValueError, match="no 'rho' section"): + read_input_gacode(path) + + +def test_a_declared_ion_count_that_disagrees_with_z_is_refused(tmp_path): + original = REG18.read_text().replace("# nion\n2\n", "# nion\n3\n", 1) + path = tmp_path / "input.gacode" + path.write_text(original) + with pytest.raises(ValueError, match="nion is 3 but 'z' lists 2"): + read_input_gacode(path) + + +def test_a_declared_point_count_that_disagrees_with_rho_is_refused(tmp_path): + original = REG18.read_text().replace("# nexp\n51\n", "# nexp\n50\n", 1) + path = tmp_path / "input.gacode" + path.write_text(original) + with pytest.raises(ValueError, match="nexp is 50 but 'rho' has 51"): + read_input_gacode(path) + + +def test_ragged_rows_are_refused(tmp_path): + path = tmp_path / "input.gacode" + path.write_text( + "#\n# z\n 1.0000000E+00\n# rho | -\n" + " 1 0.0000000E+00\n 2 5.0000000E-01 1.0000000E+00\n" + ) + with pytest.raises(ValueError, match="differing width"): + read_input_gacode(path) + + +def test_a_per_ion_section_whose_width_disagrees_with_the_species_count(tmp_path): + """Two ion species declared, one column of ion temperature written.""" + path = tmp_path / "input.gacode" + path.write_text( + "#\n" + "# z\n 1.0000000E+00 6.0000000E+00\n" + "# rho | -\n 1 0.0000000E+00\n 2 1.0000000E+00\n" + "# ti | keV\n 1 1.0000000E+00\n 2 5.0000000E-01\n" + ) + with pytest.raises(ValueError, match="'ti' has 1 columns but there are 2"): + read_input_gacode(path) + + +def test_a_profile_needs_at_least_two_radial_points(): + with pytest.raises(ValueError, match="at least two points"): + GACODEProfile(rho=np.array([0.0]), z=np.array([1.0])) + + +def test_mass_and_charge_must_describe_the_same_species(): + with pytest.raises(ValueError, match="mass has 1 entries but z has 2"): + GACODEProfile( + rho=np.linspace(0, 1, 4), z=np.array([1.0, 6.0]), mass=np.array([2.0]) + ) + + +# -------------------------------------------------------------------------- +# The ODS projection +# -------------------------------------------------------------------------- + + +@requires_sample +def test_the_packaged_48224_state_is_refused_without_an_explicit_truncation(ods_48224): + """Its fitted profiles reach exactly zero at the boundary. + + GACODE takes logarithmic gradients, so a zero is not usable. Clipping it + quietly would fabricate an edge; the conversion stops and names the grid + point instead, leaving the decision to the caller. + """ + with pytest.raises(ProfileConversionError, match="electron density is not positive"): + prepare_gacode_profile(ods_48224) + + +@requires_sample +def test_the_48224_state_converts_once_the_caller_truncates(ods_48224): + profile = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + assert profile.n_ion == 1 + assert profile.name == ("H+",) + assert profile.check_neo_requirements() == () + assert profile.n_exp < 129, "the truncation must actually drop points" + assert np.all(profile.ne > 0.0) and np.all(profile.te > 0.0) + assert profile.shot == 48224 + assert profile.time == 300 + + +@requires_sample +def test_truncation_does_not_rescale_the_radial_coordinate(ods_48224): + """`torfluxa` stays the plasma-boundary flux when the grid is cut short. + + `rho` is normalised to the boundary, so taking `phi` after truncation would + silently rescale every radius in the file. + """ + full = prepare_gacode_profile(ods_48224, rho_max=0.999, z_eff=2.0) + cut = prepare_gacode_profile(ods_48224, rho_max=0.80, z_eff=2.0) + assert cut.torfluxa == pytest.approx(full.torfluxa, rel=1e-12) + assert cut.rho[-1] < 0.81 + assert cut.n_exp < full.n_exp + + +@requires_sample +def test_the_conversion_records_all_three_times(ods_48224): + """Requested, equilibrium and core_profiles times are recorded separately.""" + profile = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + times = profile.provenance["time"] + assert times["requested_time"] == pytest.approx(0.3) + assert times["equilibrium_time"] == pytest.approx(0.3) + assert times["core_profiles_time"] == pytest.approx(0.3) + assert times["tolerance"] > 0.0 + + +@requires_sample +def test_a_time_beyond_the_tolerance_is_refused(ods_48224): + with pytest.raises(ProfileConversionError, match="beyond the .* tolerance"): + prepare_gacode_profile(ods_48224, time=0.9, rho_max=0.95) + + +@requires_sample +def test_provenance_distinguishes_measured_derived_assumed_and_absent(ods_48224): + profile = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + kinds = {name: record["kind"] for name, record in profile.provenance.items()} + assert kinds["ne"] == "measured" + assert kinds["te"] == "measured" + assert kinds["rmin"] == "derived" + assert kinds["z_eff"] == "caller_supplied" + assert kinds["zeta"] == "unavailable" + assert "zeta" in profile.missing() + + +@requires_sample +def test_a_single_ion_species_with_no_zeff_is_reported_not_invented(ods_48224): + """Zeff is a real gap in this state, and the conversion says so.""" + profile = prepare_gacode_profile(ods_48224, rho_max=0.95) + assert profile.z_eff is None + assert "z_eff" in profile.missing() + + +@requires_sample +def test_prepare_writes_a_readable_file_in_the_callers_directory(ods_48224, tmp_path): + staged = prepare_gacode_inputs(ods_48224, tmp_path / "case", rho_max=0.95, z_eff=2.0) + assert staged.input_gacode == tmp_path / "case" / "input.gacode" + assert staged.input_gacode.is_file() + reloaded = read_input_gacode(staged.input_gacode) + np.testing.assert_allclose(reloaded.rho, staged.profile.rho, rtol=1e-6) + np.testing.assert_allclose(reloaded.ne, staged.profile.ne, rtol=1e-6) + + +def test_an_ods_without_core_profiles_is_refused(): + from omas import ODS + + ods = ODS() + ods["equilibrium.time"] = np.array([0.3]) + with pytest.raises(ProfileConversionError, match="no core_profiles.time"): + prepare_gacode_profile(ods) + + +def test_an_ods_without_an_equilibrium_is_refused(): + from omas import ODS + + with pytest.raises(ProfileConversionError, match="no equilibrium.time"): + prepare_gacode_profile(ODS()) + + +@requires_sample +def test_ion_rotation_is_carried_when_every_species_has_it(ods_48224): + """48224 carries a toroidal velocity, so it reaches the file rather than being read and dropped.""" + profile = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + assert profile.vtor is not None + assert profile.vtor.shape == (profile.n_ion, profile.n_exp) + assert profile.provenance["vtor"]["kind"] == "measured" + + +def test_rotation_is_left_absent_when_a_species_lacks_it(): + """A per-ion array with one species zeroed would claim a stationary impurity.""" + from omas import ODS + + rho = np.linspace(0.0, 1.0, 9) + ods = ODS(consistency_check=False) + ods["equilibrium.time"] = np.array([0.3]) + ods["core_profiles.time"] = np.array([0.3]) + eq = "equilibrium.time_slice.0.profiles_1d" + ods[f"{eq}.rho_tor_norm"] = rho + ods[f"{eq}.phi"] = rho**2 + ods[f"{eq}.psi"] = np.linspace(0.0, 0.05, rho.size) + ods[f"{eq}.q"] = np.linspace(1.0, 3.0, rho.size) + ods[f"{eq}.r_inboard"] = np.linspace(0.4, 0.1, rho.size) + ods[f"{eq}.r_outboard"] = np.linspace(0.4, 0.7, rho.size) + ods["equilibrium.time_slice.0.global_quantities.ip"] = 1.0e5 + cp = "core_profiles.profiles_1d.0" + ods[f"{cp}.grid.rho_tor_norm"] = rho + ods[f"{cp}.electrons.density_thermal"] = np.linspace(1e19, 1e18, rho.size) + ods[f"{cp}.electrons.temperature"] = np.linspace(100.0, 10.0, rho.size) + for index, charge in enumerate((1.0, 6.0)): + ods[f"{cp}.ion.{index}.label"] = "H+" if index == 0 else "C6+" + ods[f"{cp}.ion.{index}.z_ion"] = charge + ods[f"{cp}.ion.{index}.density_thermal"] = np.linspace(1e19, 1e18, rho.size) + ods[f"{cp}.ion.{index}.temperature"] = np.linspace(80.0, 8.0, rho.size) + # Only the main ion is measured. + ods[f"{cp}.ion.0.velocity.toroidal"] = np.linspace(1e4, 0.0, rho.size) + + profile = prepare_gacode_profile(ods) + assert profile.vtor is None + assert profile.provenance["vtor"]["kind"] == "unavailable" + # Two ion species and no zeff profile: Zeff is derivable and is derived. + assert profile.z_eff is not None + assert profile.provenance["z_eff"]["kind"] == "derived" + assert profile.z_eff[0] == pytest.approx( + (1.0 * 1.0**2 + 1.0 * 6.0**2) / 1.0, rel=1e-9 + ) + + +# -------------------------------------------------------------------------- +# Review findings: signs, the toroidal flux, and the field's time +# -------------------------------------------------------------------------- + + +def _directions(profile: GACODEProfile) -> tuple[int, int]: + """(btccw, ipccw) exactly as expro derives them (expro_locsim.f90:202-203).""" + signb = int(np.sign(profile.torfluxa)) + signq = int(np.sign(profile.q[0])) + return -signb, -signq * signb + + +def test_the_gacode_convention_is_registered_and_marked_as_inferred(): + from vaft.data.cocos import convention_for + + convention = convention_for("gacode") + assert convention.cocos == 2 + assert convention.confirmed is False + + +def test_reg18_is_self_consistent_with_that_convention(reg18_profile): + """DIII-D in the normal orientation: Bt clockwise, Ip counter-clockwise.""" + assert _directions(reg18_profile) == (-1, +1) + + +@requires_sample +def test_the_converted_file_gives_neo_the_imas_field_directions(ods_48224): + """48224 has b0 > 0 and ip > 0: both counter-clockwise under COCOS 11. + + Written without the COCOS 11 -> 2 transform, expro would read both as + clockwise -- the device mirrored. survives that (the helicity is + preserved), which is why the scalar cross-checks could not catch it. + """ + profile = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + b0 = float(np.ravel(ods_48224["equilibrium.vacuum_toroidal_field.b0"])[0]) + ip = float(ods_48224["equilibrium.time_slice.0.global_quantities.ip"]) + assert _directions(profile) == (int(np.sign(b0)), int(np.sign(ip))) + assert profile.provenance["cocos"]["to"] == 2 + + +@requires_sample +def test_toroidal_components_change_sign_and_q_does_not(ods_48224): + profile = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + eq = "equilibrium.time_slice.0.profiles_1d" + assert np.sign(profile.bcentr) == -np.sign( + float(np.ravel(ods_48224["equilibrium.vacuum_toroidal_field.b0"])[0]) + ) + assert np.sign(profile.current) == -np.sign( + float(ods_48224["equilibrium.time_slice.0.global_quantities.ip"]) + ) + np.testing.assert_allclose( + profile.fpol, -np.asarray(ods_48224[f"{eq}.f"])[: profile.n_exp] + ) + np.testing.assert_allclose(profile.q, np.asarray(ods_48224[f"{eq}.q"])[: profile.n_exp]) + + +@requires_sample +def test_torfluxa_is_phi_over_two_pi_whatever_the_psi_convention(ods_48224, monkeypatch): + """phi is written in weber by every VAFT producer, however psi is stored. + + Forcing the psi-storage probe to report Wb/rad must not change torfluxa; + it used to, by a factor of 2*pi. + """ + import vaft.data.eqdsk as eqdsk + + expected = -float(ods_48224["equilibrium.time_slice.0.profiles_1d.phi"][-1]) / (2 * np.pi) + stored_in_weber = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + monkeypatch.setattr(eqdsk, "ods_psi_to_wb_per_radian_factor", lambda *a, **k: 1.0) + stored_per_radian = prepare_gacode_profile(ods_48224, rho_max=0.95, z_eff=2.0) + assert stored_in_weber.torfluxa == pytest.approx(expected, rel=1e-12) + assert stored_per_radian.torfluxa == pytest.approx(expected, rel=1e-12) + + +def test_bcentr_is_read_at_the_converted_slice(): + """b0 lives on the equilibrium time base; index 0 is the wrong instant.""" + from omas import ODS + + rho = np.linspace(0.0, 1.0, 9) + ods = ODS(consistency_check=False) + ods["equilibrium.time"] = np.array([0.2, 0.3]) + ods["core_profiles.time"] = np.array([0.2, 0.3]) + ods["equilibrium.vacuum_toroidal_field.r0"] = 0.4 + ods["equilibrium.vacuum_toroidal_field.b0"] = np.array([0.10, 0.25]) + for index in (0, 1): + eq = f"equilibrium.time_slice.{index}.profiles_1d" + ods[f"{eq}.rho_tor_norm"] = rho + ods[f"{eq}.phi"] = rho**2 + ods[f"{eq}.psi"] = np.linspace(0.0, 0.05, rho.size) + ods[f"{eq}.q"] = np.linspace(1.0, 3.0, rho.size) + ods[f"equilibrium.time_slice.{index}.global_quantities.ip"] = 1.0e5 + cp = f"core_profiles.profiles_1d.{index}" + ods[f"{cp}.grid.rho_tor_norm"] = rho + ods[f"{cp}.electrons.density_thermal"] = np.linspace(1e19, 1e18, rho.size) + ods[f"{cp}.electrons.temperature"] = np.linspace(100.0, 10.0, rho.size) + ods[f"{cp}.ion.0.label"] = "H+" + ods[f"{cp}.ion.0.z_ion"] = 1.0 + ods[f"{cp}.ion.0.density_thermal"] = np.linspace(1e19, 1e18, rho.size) + ods[f"{cp}.ion.0.temperature"] = np.linspace(80.0, 8.0, rho.size) + + profile = prepare_gacode_profile(ods, time_index=1) + assert abs(profile.bcentr) == pytest.approx(0.25) diff --git a/vaft/code/__init__.py b/vaft/code/__init__.py index 2714c144..b88bd50b 100644 --- a/vaft/code/__init__.py +++ b/vaft/code/__init__.py @@ -36,6 +36,12 @@ "collect_gpec_suite_outputs", "efit", "efit_parameter_grid", + "gacode", + "GACODEConfig", + "find_gacode_executable", + "gacode_environment", + "gacode_home", + "gacode_platform", "format_gfile_header_for_gpec", "CHEASEScanCase", "EquilibriumVariation", @@ -129,6 +135,11 @@ ] _EXPORT_MAP = { + "GACODEConfig": (".gacode", "GACODEConfig"), + "find_gacode_executable": (".gacode", "find_gacode_executable"), + "gacode_environment": (".gacode", "gacode_environment"), + "gacode_home": (".gacode", "gacode_home"), + "gacode_platform": (".gacode", "gacode_platform"), "CodeConfig": (".base", "CodeConfig"), "CodeInputs": (".base", "CodeInputs"), "CodeResult": (".base", "CodeResult"), @@ -250,6 +261,7 @@ def __getattr__(name: str): if name in { "base", "efit", + "gacode", "gpec", "chease", "nubeam", diff --git a/vaft/code/gacode/__init__.py b/vaft/code/gacode/__init__.py new file mode 100644 index 00000000..34d8ec64 --- /dev/null +++ b/vaft/code/gacode/__init__.py @@ -0,0 +1,88 @@ +"""Adapters for the GACODE suite: shared runtime, profiles, and NEO. + +GACODE is one source tree carrying several solvers, so this package owns the +boundary once rather than once per solver: + + equilibrium + core_profiles + | + v + GACODEProfile vaft.code.gacode + | + v + input.gacode the suite's shared profile spine + | + +-------+--------+ + v v + NEO TGLF / CGYRO (issue #553) + +``input.gacode`` is an interoperability format, not VAFT's kinetic state: the +canonical state stays in IMAS/OMAS and is converted deterministically here. + +Importing this package does not require GACODE. Executable and platform +resolution happen when something is run, so ``from vaft.code import *`` and the +whole test suite work with the suite absent. + +Typical use:: + + from vaft.code.gacode import GACODEConfig, neo + + config = GACODEConfig(home="~/git/gacode", platform="GFORTRAN_OSX_BREW") + result = neo.run_neo_case(profile, workdir="runs/48224", config=config) + result.outputs_native.bootstrap_current_parallel +""" + +from __future__ import annotations + +from ._runtime import ( + available_platforms, + find_gacode_executable, + gacode_environment, + gacode_home, + gacode_platform, + launcher_relative_path, + require_gacode_executable, + run_gacode, +) +from ._types import ( + GACODE_COMPATIBILITY_ENVS, + GACODE_HOME_ENV, + GACODE_PLATFORM_ENV, + GACODE_ROOT_ENV, + GACODEConfig, + SUITE_CODES, + SUPPORTED_CODES, +) + +__all__ = [ + "GACODEConfig", + "GACODE_COMPATIBILITY_ENVS", + "GACODE_HOME_ENV", + "GACODE_PLATFORM_ENV", + "GACODE_ROOT_ENV", + "SUITE_CODES", + "SUPPORTED_CODES", + "available_platforms", + "find_gacode_executable", + "gacode_environment", + "gacode_home", + "gacode_platform", + "launcher_relative_path", + "require_gacode_executable", + "run_gacode", +] + + +def __getattr__(name: str): + # `neo` is a subpackage, imported on first use so that `vaft.code.gacode` + # itself stays as light as the rest of `vaft.code`. + if name == "neo": + from importlib import import_module + + module = import_module(".neo", __name__) + globals()["neo"] = module + return module + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted([*__all__, "neo"]) diff --git a/vaft/code/gacode/_input_gacode.py b/vaft/code/gacode/_input_gacode.py new file mode 100644 index 00000000..02aff8f9 --- /dev/null +++ b/vaft/code/gacode/_input_gacode.py @@ -0,0 +1,375 @@ +"""Read and write ``input.gacode``, in pure Python. + +GACODE ships its own reader as ``pygacode``, but that is an f2py extension +built from ``f2py/expro/expro.f90``, so using it would make a Fortran toolchain +a requirement for reading a text file. The format is a tagged flat file and is +reproduced here directly against ``expro_write`` and its helpers in +``f2py/expro/expro_util.f90``. + +Layout:: + + # *original : ... six free-text header lines, fixed order + ... + # + # nexp integer, format i0 + 51 + # torfluxa | Wb/radian scalar, format 1pe14.7 + 6.1675847E-01 + # rho | - profile, format (i3,1x,1pe14.7) + 1 0.0000000E+00 + # ni | 10^19/m^3 per-ion profile, format (i3,1x,10(1pe14.7,1x)) + 1 5.3635000E+00 1.7599000E-01 + +Two behaviours of ``expro`` the writer here reproduces deliberately: + +* **All-zero objects are omitted.** ``expro_writev`` skips a vector whose + absolute sum is below 1e-16. So a tag's *absence* from a file carries no + information beyond "not set or identically zero", and the reader must not + invent a zero array for a missing tag. +* **The unit strings in an existing file may be stale.** ``qpar_beam`` is + labelled ``MW/m^3`` in files written by older versions and ``1/m^3/s`` today. + The reader therefore keys on the tag and ignores the unit; the writer emits + the current one. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Iterable, Mapping + +import numpy as np + +from ._profiles import SHAPE_COS_FIELDS, SHAPE_SIN_FIELDS, SOURCE_FIELDS, GACODEProfile + +#: The six free-text header lines, in the order ``expro_write`` emits them. +HEADER_KEYS = ("original", "statefile", "gfile", "cerfile", "vgen", "tgyro") + +#: Integer tags, written by ``expro_writei`` and omitted when not positive. +INTEGER_TAGS = ("nexp", "nion", "shot", "time") + +#: Whitespace-separated string lists, one entry per ion. +STRING_TAGS = ("name", "type") + +#: Bare floats with no unit and no index: one value, or one per ion. +BARE_FLOAT_TAGS = ("masse", "mass", "ze", "z") + +#: Unit-carrying scalars, written by ``expro_writes``. +SCALAR_TAGS: tuple[tuple[str, str], ...] = ( + ("torfluxa", "Wb/radian"), + ("rcentr", "m"), + ("bcentr", "T"), + ("current", "MA"), +) + +#: Unit-carrying profiles, in ``expro_write`` order. ``per_ion`` marks the +#: tags written by ``expro_writea`` with one column per ion species. +PROFILE_TAGS: tuple[tuple[str, str, bool], ...] = ( + ("rho", "-", False), + ("rmin", "m", False), + ("polflux", "Wb/radian", False), + ("q", "-", False), + ("w0", "rad/s", False), + ("rmaj", "m", False), + ("zmag", "m", False), + ("kappa", "-", False), + ("delta", "-", False), + ("zeta", "-", False), + *((name, "-", False) for name in SHAPE_COS_FIELDS), + *((name, "-", False) for name in SHAPE_SIN_FIELDS), + ("ne", "10^19/m^3", False), + ("ni", "10^19/m^3", True), + ("te", "keV", False), + ("ti", "keV", True), + ("ptot", "Pa", False), + ("fpol", "T-m", False), + ("johm", "MA/m^2", False), + ("jbs", "MA/m^2", False), + ("jrf", "MA/m^2", False), + ("jnb", "MA/m^2", False), + ("jbstor", "MA/m^2", False), + ("sigmapar", "MSiemens/m", False), + ("z_eff", "-", False), + ("vpol", "m/s", True), + ("vtor", "m/s", True), + ("qohme", "MW/m^3", False), + ("qbeame", "MW/m^3", False), + ("qbeami", "MW/m^3", False), + ("qrfe", "MW/m^3", False), + ("qrfi", "MW/m^3", False), + ("qfuse", "MW/m^3", False), + ("qfusi", "MW/m^3", False), + ("qbrem", "MW/m^3", False), + ("qsync", "MW/m^3", False), + ("qline", "MW/m^3", False), + ("qei", "MW/m^3", False), + ("qione", "MW/m^3", False), + ("qioni", "MW/m^3", False), + ("qcxi", "MW/m^3", False), + ("qpar_beam", "1/m^3/s", False), + ("qpar_wall", "1/m^3/s", False), + ("qmom", "N/m^2", False), +) + +_PROFILE_UNITS = {name: unit for name, unit, _ in PROFILE_TAGS} +_PER_ION = {name for name, _, per_ion in PROFILE_TAGS if per_ion} + +#: expro_writev/writes skip anything whose absolute sum is below this. +ZERO_TOLERANCE = 1e-16 + + +def _fortran_float(value: float) -> str: + """Format one value as Fortran ``1pe14.7``: a signed 14-character field.""" + return f"{float(value): .7E}" + + +def _split_sections(lines: Iterable[str]) -> tuple[dict[str, str], list[tuple[str, list[str]]]]: + """Split the file into its header block and its ``# tag`` sections.""" + header: dict[str, str] = {} + sections: list[tuple[str, list[str]]] = [] + current: tuple[str, list[str]] | None = None + in_header = True + + for raw in lines: + line = raw.rstrip("\n\r") + if in_header: + stripped = line.strip() + if stripped == "#": + in_header = False + continue + if stripped.startswith("#") and ":" in stripped: + key, _, value = stripped[1:].partition(":") + header[key.strip().lstrip("*")] = value.strip() + continue + # A file with no header block at all: fall through and treat this + # line as the start of the data. + in_header = False + + if line.startswith("#"): + tag = line[1:].split("|")[0].strip() + current = (tag, []) + sections.append(current) + elif current is not None and line.strip(): + current[1].append(line) + return header, sections + + +def _profile_columns(rows: list[str], tag: str) -> np.ndarray: + """Parse ``index value...`` rows into ``(columns, rows)``, dropping the index.""" + parsed = [] + for row in rows: + fields = row.split() + if len(fields) < 2: + raise ValueError(f"input.gacode: malformed row in section {tag!r}: {row!r}") + parsed.append([float(value) for value in fields[1:]]) + widths = {len(row) for row in parsed} + if len(widths) != 1: + raise ValueError( + f"input.gacode: section {tag!r} has rows of differing width {sorted(widths)}" + ) + array = np.asarray(parsed, dtype=float) + return array[:, 0] if array.shape[1] == 1 else array.T + + +def read_input_gacode(path: str | Path) -> GACODEProfile: + """Read an ``input.gacode`` file into a :class:`GACODEProfile`. + + Every tag is preserved: modelled ones land on their field, the volumetric + source terms in ``sources``, shape harmonics in ``shape``, and anything + unrecognised in ``extra`` so that a round trip is lossless. + + Raises + ------ + ValueError + The file has no ``rho`` section, ragged rows, or a per-ion section whose + width disagrees with the declared ion count. + """ + source = Path(path) + header, sections = _split_sections( + source.read_text(encoding="utf-8", errors="replace").splitlines() + ) + + values: dict[str, Any] = {} + unknown: dict[str, Any] = {} + for tag, rows in sections: + if not rows: + continue + if tag in INTEGER_TAGS: + values[tag] = int(float(rows[0].split()[0])) + elif tag in STRING_TAGS: + values[tag] = tuple(rows[0].split()) + elif tag in BARE_FLOAT_TAGS: + numbers = [float(value) for value in " ".join(rows).split()] + values[tag] = numbers[0] if len(numbers) == 1 else np.asarray(numbers) + elif tag in dict(SCALAR_TAGS): + values[tag] = float(rows[0].split()[0]) + elif tag in _PROFILE_UNITS: + values[tag] = _profile_columns(rows, tag) + else: + unknown[tag] = _profile_columns(rows, tag) + + if "rho" not in values: + raise ValueError(f"input.gacode: {source} has no 'rho' section") + + charge = values.get("z") + if charge is None: + raise ValueError(f"input.gacode: {source} has no 'z' section") + charge = np.atleast_1d(np.asarray(charge, dtype=float)) + + declared_ions = values.get("nion") + if declared_ions is not None and int(declared_ions) != charge.size: + raise ValueError( + f"input.gacode: nion is {declared_ions} but 'z' lists {charge.size} species" + ) + declared_points = values.get("nexp") + rho = np.asarray(values["rho"], dtype=float) + if declared_points is not None and int(declared_points) != rho.size: + raise ValueError( + f"input.gacode: nexp is {declared_points} but 'rho' has {rho.size} points" + ) + + for tag in _PER_ION: + array = values.get(tag) + if array is None: + continue + array = np.atleast_2d(array) + if array.shape[0] != charge.size: + raise ValueError( + f"input.gacode: section {tag!r} has {array.shape[0]} columns " + f"but there are {charge.size} ion species" + ) + values[tag] = array + + shape = { + name: values.pop(name) + for name in (*SHAPE_COS_FIELDS, *SHAPE_SIN_FIELDS) + if name in values + } + sources = {name: values.pop(name) for name in SOURCE_FIELDS if name in values} + mass = values.get("mass") + + profile = GACODEProfile( + rho=rho, + z=charge, + shape=shape, + sources=sources, + extra=unknown, + header=header, + mass=None if mass is None else np.atleast_1d(np.asarray(mass, dtype=float)), + masse=float(values.get("masse", 5.4488741e-04)), + ze=float(values.get("ze", -1.0)), + name=values.get("name", ()), + type=values.get("type", ()), + shot=values.get("shot"), + time=values.get("time"), + **{ + key: values[key] + for key in ( + "rmin", "polflux", "q", "w0", "rmaj", "zmag", "kappa", "delta", + "zeta", "ne", "ni", "te", "ti", "ptot", "z_eff", "vpol", "vtor", + "fpol", "johm", "jbs", "jrf", "jnb", "jbstor", "sigmapar", + "torfluxa", "rcentr", "bcentr", "current", + ) + if key in values + }, + ) + profile.provenance = { + name: {"kind": "caller_supplied", "source": str(source)} + for name in (*values, *shape, *sources, *unknown) + if name not in {"nexp", "nion", "rho", "z"} + } + return profile + + +def _section(tag: str, unit: str | None) -> str: + return f"# {tag}\n" if unit is None else f"# {tag} | {unit}\n" + + +def _write_profile(tag: str, unit: str, values: np.ndarray) -> str: + """Render one profile section, or nothing when it is identically zero.""" + array = np.asarray(values, dtype=float) + if array.size == 0 or float(np.sum(np.abs(array))) <= ZERO_TOLERANCE: + return "" + text = [_section(tag, unit)] + if array.ndim == 1: + for index, value in enumerate(array, start=1): + text.append(f"{index:3d} {_fortran_float(value)}\n") + else: + for index in range(array.shape[1]): + columns = " ".join(_fortran_float(value) for value in array[:, index]) + text.append(f"{index + 1:3d} {columns}\n") + return "".join(text) + + +def write_input_gacode(profile: GACODEProfile, path: str | Path) -> Path: + """Write a :class:`GACODEProfile` as ``input.gacode`` and return the path. + + The output reproduces ``expro_write``: the same tag order, the same + ``1pe14.7`` formatting, and the same omission of identically-zero objects, + so that GACODE reads back exactly what it would have written. + """ + target = Path(path) + header = dict(profile.header) + # expro declares these as character(len=70) with the starred tag + # right-aligned to column 12, and Fortran writes the whole fixed-width + # field, so the trailing padding is part of the format. + text = [ + f"# {'*' + key:>10s} : {header.get(key, 'null')}".ljust(70) + "\n" + for key in HEADER_KEYS + ] + text.append("#\n") + + integers = { + "nexp": profile.n_exp, + "nion": profile.n_ion, + "shot": profile.shot, + "time": profile.time, + } + for tag in INTEGER_TAGS: + value = integers[tag] + if value is not None and int(value) > 0: + text.append(_section(tag, None)) + text.append(f"{int(value)}\n") + + names = tuple(profile.name) or tuple(f"i{i + 1}" for i in range(profile.n_ion)) + kinds = tuple(profile.type) or ("[therm]",) * profile.n_ion + # expro's "(20(a,1x))" would leave a trailing blank, and its + # "(10(1pe14.7,1x))" one per row. The files GACODE ships carry none, and + # its parser splits on whitespace either way, so the reference artifact is + # what is reproduced here. + text.append(_section("name", None)) + text.append(" ".join(names) + "\n") + text.append(_section("type", None)) + text.append(" ".join(kinds) + "\n") + text.append(_section("masse", None)) + text.append(_fortran_float(profile.masse) + "\n") + if profile.mass is not None: + text.append(_section("mass", None)) + text.append("".join(_fortran_float(v) for v in profile.mass) + "\n") + text.append(_section("ze", None)) + text.append(_fortran_float(profile.ze) + "\n") + text.append(_section("z", None)) + text.append("".join(_fortran_float(v) for v in profile.z) + "\n") + + for tag, unit in SCALAR_TAGS: + value = getattr(profile, tag, None) + if value is not None and abs(float(value)) > ZERO_TOLERANCE: + text.append(_section(tag, unit)) + text.append(_fortran_float(value) + "\n") + + for tag, unit, _per_ion in PROFILE_TAGS: + if tag in profile.shape: + values = profile.shape[tag] + elif tag in profile.sources: + values = profile.sources[tag] + else: + values = getattr(profile, tag, None) + if values is None: + continue + text.append(_write_profile(tag, unit, values)) + + for tag, values in profile.extra.items(): + text.append(_write_profile(tag, "-", np.asarray(values))) + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("".join(text), encoding="utf-8") + return target diff --git a/vaft/code/gacode/_profiles.py b/vaft/code/gacode/_profiles.py new file mode 100644 index 00000000..55247e6b --- /dev/null +++ b/vaft/code/gacode/_profiles.py @@ -0,0 +1,179 @@ +"""The typed VAFT-side representation of a GACODE profile set. + +``input.gacode`` is the GACODE suite's shared profile spine: NEO, TGLF and +CGYRO all read it. It is *not* VAFT's kinetic state. The canonical state +stays in IMAS/OMAS ``equilibrium`` and ``core_profiles``, and this object is a +deterministic, provenance-preserving projection of it, so that one external +code family's conventions never leak back into the schema-facing layer. + +Two rules the fields encode: + +* **Optional means absent, not zero.** Every field GACODE treats as optional is + ``None`` when VAFT does not have it. ``expro`` itself omits an all-zero + profile when it writes, so a zero-filled array is indistinguishable from a + physical zero once written -- which is exactly the confusion issue #550 asks + this layer not to create. +* **Units are GACODE's, not SI.** Densities are 10^19 m^-3, temperatures keV, + currents MA and MA/m^2, conductivity MSiemens/m. The conversion happens once, + at the boundary, and is recorded in ``provenance``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional, Sequence + +import numpy as np + +#: How a field of a GACODEProfile came to hold what it holds. Kept alongside +#: the values because "we measured this", "we derived it", "the machine policy +#: assumed it" and "the caller supplied it" are different scientific claims, +#: and a GACODE file cannot express the difference on its own. +PROVENANCE_KINDS = ( + "measured", + "derived", + "policy_assumption", + "caller_supplied", + "unavailable", +) + +#: Shape harmonics ``expro`` carries beyond the elementary Miller set. +SHAPE_COS_FIELDS = tuple(f"shape_cos{index}" for index in range(7)) +SHAPE_SIN_FIELDS = tuple(f"shape_sin{index}" for index in range(3, 7)) + +#: Volumetric source and sink terms. Homogeneous family, carried as a mapping +#: rather than as thirty dataclass fields, because VAFT models none of them +#: individually yet and a round trip must still preserve them. +SOURCE_FIELDS = ( + "qohme", "qbeame", "qbeami", "qrfe", "qrfi", "qfuse", "qfusi", + "qbrem", "qsync", "qline", "qei", "qione", "qioni", "qcxi", + "qpar_beam", "qpar_wall", "qmom", +) + + +@dataclass +class GACODEProfile: + """One GACODE profile set: geometry, species, kinetics and provenance. + + Attributes + ---------- + rho + Normalised square-root toroidal flux, the GACODE radial coordinate. + This is ``sqrt(Phi/Phi_boundary)``, and it is **not** ``sqrt(psi_N)``. + z + Ion charge numbers, one per ion species; its length defines ``n_ion``. + ni, ti, vpol, vtor + Per-ion profiles, shaped ``(n_ion, n_exp)``. + sources + Volumetric source terms keyed by their GACODE tag. + extra + Tags read from a file that this class does not model, kept so that a + read/write round trip loses nothing. + provenance + Per-field record, keyed by field name, whose ``kind`` is one of + :data:`PROVENANCE_KINDS`. + """ + + # Radial coordinate and geometry + rho: np.ndarray + z: np.ndarray + + rmin: Optional[np.ndarray] = None + polflux: Optional[np.ndarray] = None + q: Optional[np.ndarray] = None + w0: Optional[np.ndarray] = None + rmaj: Optional[np.ndarray] = None + zmag: Optional[np.ndarray] = None + kappa: Optional[np.ndarray] = None + delta: Optional[np.ndarray] = None + zeta: Optional[np.ndarray] = None + shape: Mapping[str, np.ndarray] = field(default_factory=dict) + + # Species identity + name: Sequence[str] = () + type: Sequence[str] = () + mass: Optional[np.ndarray] = None + masse: float = 5.4488741e-04 + ze: float = -1.0 + + # Global scalars + shot: Optional[int] = None + time: Optional[int] = None + torfluxa: Optional[float] = None + rcentr: Optional[float] = None + bcentr: Optional[float] = None + current: Optional[float] = None + + # Kinetic profiles + ne: Optional[np.ndarray] = None + ni: Optional[np.ndarray] = None + te: Optional[np.ndarray] = None + ti: Optional[np.ndarray] = None + ptot: Optional[np.ndarray] = None + z_eff: Optional[np.ndarray] = None + vpol: Optional[np.ndarray] = None + vtor: Optional[np.ndarray] = None + + # Current and conductivity + fpol: Optional[np.ndarray] = None + johm: Optional[np.ndarray] = None + jbs: Optional[np.ndarray] = None + jrf: Optional[np.ndarray] = None + jnb: Optional[np.ndarray] = None + jbstor: Optional[np.ndarray] = None + sigmapar: Optional[np.ndarray] = None + + sources: Mapping[str, np.ndarray] = field(default_factory=dict) + extra: Mapping[str, Any] = field(default_factory=dict) + + #: Free-text header lines, in expro's fixed six-line order. + header: Mapping[str, str] = field(default_factory=dict) + provenance: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.rho = np.asarray(self.rho, dtype=float) + self.z = np.atleast_1d(np.asarray(self.z, dtype=float)) + if self.rho.ndim != 1: + raise ValueError(f"rho must be one-dimensional; got shape {self.rho.shape}") + if self.rho.size < 2: + raise ValueError("rho must have at least two points") + if self.mass is not None: + self.mass = np.atleast_1d(np.asarray(self.mass, dtype=float)) + if self.mass.size != self.z.size: + raise ValueError( + f"mass has {self.mass.size} entries but z has {self.z.size}" + ) + + @property + def n_exp(self) -> int: + """Number of radial points.""" + return int(self.rho.size) + + @property + def n_ion(self) -> int: + """Number of ion species.""" + return int(self.z.size) + + def missing(self) -> tuple[str, ...]: + """Names this profile records as unavailable, in provenance order.""" + return tuple( + sorted( + name + for name, record in self.provenance.items() + if record.get("kind") == "unavailable" + ) + ) + + def check_neo_requirements(self) -> tuple[str, ...]: + """Fields NEO needs for ``PROFILE_MODEL=2`` that this profile lacks. + + A precondition check, not a verdict: it reports what is missing and + leaves the decision to the caller, per the boundary in issue #253. + """ + # kappa is required even though delta and zmag are not: expro reads an + # absent tag as zero, which is a legitimate delta or zmag but collapses + # every surface when it is the elongation, and NEO then returns NaN + # without logging an error. + required = ("rmin", "polflux", "q", "rmaj", "kappa", "ne", "te", "ni", "ti", + "torfluxa", "rcentr", "bcentr", "current") + return tuple(name for name in required if getattr(self, name, None) is None) diff --git a/vaft/code/gacode/_runtime.py b/vaft/code/gacode/_runtime.py new file mode 100644 index 00000000..edfdd676 --- /dev/null +++ b/vaft/code/gacode/_runtime.py @@ -0,0 +1,241 @@ +"""Executable discovery and subprocess environment for the GACODE suite. + +Solver-agnostic: everything here is true of NEO, TGLF and CGYRO alike, so a +second backend reuses it unchanged. + +Two things about GACODE's launchers are worth stating because both fail as +something else: + +* ``/bin/`` is a shell script that shells out to + ``_parse.py``, which imports ``gacodeinput`` from ``f2py/pygacode``. + When that import fails the launcher does **not** stop -- it carries on, and + the Fortran binary then aborts on a missing ``./input..gen``, which + points at the wrong thing entirely. :func:`gacode_environment` therefore + always puts ``pygacode`` on ``PYTHONPATH``. +* The launcher execs ``platform/exec/exec.$GACODE_PLATFORM``. An unset or + wrong ``GACODE_PLATFORM`` produces a shell error naming a path, not the + variable, so :func:`gacode_platform` resolves it before anything is launched + and lists the platforms the installation actually carries. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +from typing import Sequence + +from .._executables import ( + ExecutableNotLaunchable, + executable_from_home, + missing_home_message, +) +from ._types import ( + GACODE_COMPATIBILITY_ENVS, + GACODE_HOME_ENV, + GACODE_PLATFORM_ENV, + GACODE_ROOT_ENV, + GACODEConfig, + SUITE_CODES, +) + + +def _validated_code(code: str) -> str: + """Normalise a suite-member name, refusing anything not in the suite.""" + name = str(code).strip().lower() + if name not in SUITE_CODES: + raise ValueError( + f"{code!r} is not a GACODE suite member; expected one of " + f"{', '.join(SUITE_CODES)}." + ) + return name + + +def launcher_relative_path(code: str) -> Path: + """Where a suite member's launcher sits beneath the installation root. + + Not ``bin/``: GACODE gives every suite member its own ``bin``, so the + path is ``/bin/``. + """ + name = _validated_code(code) + return Path(name) / "bin" / name + + +def gacode_home(config: GACODEConfig | None = None) -> Path | None: + """Resolve the installation root, or ``None`` when nothing is configured. + + Order: the config, then ``$GACODEHOME``, then ``$GACODE_ROOT``. An + unconfigured installation is not an error here -- it becomes one only when + something is actually run, which is what keeps ``import vaft.code.gacode`` + working with GACODE absent. + """ + if config is not None and config.home and str(config.home).strip(): + return Path(str(config.home)).expanduser() + for variable in (GACODE_HOME_ENV, *GACODE_COMPATIBILITY_ENVS): + value = os.environ.get(variable) + if value and value.strip(): + return Path(value).expanduser() + return None + + +def available_platforms(home: Path) -> tuple[str, ...]: + """Platform tags this installation carries, from ``platform/build``.""" + build = Path(home) / "platform" / "build" + if not build.is_dir(): + return () + prefix = "make.inc." + return tuple( + sorted( + entry.name[len(prefix) :] + for entry in build.iterdir() + if entry.is_file() and entry.name.startswith(prefix) + ) + ) + + +def gacode_platform(config: GACODEConfig | None = None, *, home: Path | None = None) -> str: + """Resolve the platform tag, refusing to guess one. + + Raises + ------ + ValueError + Nothing configured it, or it names a platform this installation does + not carry. Both messages list what is available. + """ + root = home if home is not None else gacode_home(config) + provided = None + if config is not None and config.platform and str(config.platform).strip(): + provided = str(config.platform).strip() + else: + value = os.environ.get(GACODE_PLATFORM_ENV) + if value and value.strip(): + provided = value.strip() + + known = available_platforms(root) if root is not None else () + if provided is None: + listed = f" This installation provides: {', '.join(known)}." if known else "" + raise ValueError( + f"GACODE platform is not configured: set ${GACODE_PLATFORM_ENV}, or " + f"pass platform= on the config. It selects " + f"platform/exec/exec.$GACODE_PLATFORM, which the launcher execs." + f"{listed}" + ) + if known and provided not in known: + raise ValueError( + f"GACODE platform {provided!r} is not built in this installation. " + f"Available: {', '.join(known)}." + ) + return provided + + +def find_gacode_executable( + config: GACODEConfig | None = None, code: str = "neo" +) -> Path | None: + """Resolve one suite member's launcher, or ``None`` when unconfigured. + + Raises ``FileNotFoundError`` when the root is set but the launcher is not + there, and ``PermissionError`` when it is there but is not a program -- + the same three-way split every other adapter in :mod:`vaft.code` makes. + """ + name = _validated_code(code) + if config is not None and config.executable and str(config.executable).strip(): + return Path(str(config.executable)).expanduser() + return executable_from_home( + gacode_home(config), + home_variable=GACODE_HOME_ENV, + relative_path=launcher_relative_path(name), + code_name=f"GACODE suite ({name})", + ) + + +def require_gacode_executable( + config: GACODEConfig | None = None, code: str = "neo" +) -> Path: + """Resolve one launcher, raising an actionable error when unconfigured.""" + name = _validated_code(code) + executable = find_gacode_executable(config, name) + if executable is None: + raise FileNotFoundError( + missing_home_message( + home_variable=GACODE_HOME_ENV, + relative_path=launcher_relative_path(name), + code_name=f"GACODE suite ({name})", + compatibility_variables=GACODE_COMPATIBILITY_ENVS, + ) + ) + return executable + + +def gacode_environment(config: GACODEConfig | None = None, code: str = "neo") -> dict[str, str]: + """Build the environment a GACODE launcher needs. + + ``GACODE_ROOT`` and ``GACODE_PLATFORM`` are *set* from the VAFT-side root + rather than replaced in meaning, and ``PATH`` and ``PYTHONPATH`` are + prefixed rather than overwritten, so a caller who already sourced + ``gacode_setup`` sees no change. Anything in ``config.env`` wins, last. + """ + name = _validated_code(code) + environment = dict(os.environ) + home = gacode_home(config) + if home is not None: + root = str(home) + environment[GACODE_HOME_ENV] = root + environment[GACODE_ROOT_ENV] = root + environment[GACODE_PLATFORM_ENV] = gacode_platform(config, home=home) + environment["PATH"] = os.pathsep.join( + [ + str(home / "shared" / "bin"), + str(home / name / "bin"), + environment.get("PATH", ""), + ] + ).rstrip(os.pathsep) + # Without this the launcher's parse step fails silently; see the module + # docstring. + environment["PYTHONPATH"] = os.pathsep.join( + [ + str(home / "f2py"), + str(home / "f2py" / "pygacode"), + environment.get("PYTHONPATH", ""), + ] + ).rstrip(os.pathsep) + if config is not None: + environment.update({str(k): str(v) for k, v in config.env.items()}) + return environment + + +def run_gacode( + executable: Path, + arguments: Sequence[str], + *, + cwd: Path, + log_path: Path, + config: GACODEConfig | None = None, + code: str = "neo", +) -> tuple[int, Path]: + """Run a GACODE launcher, capturing merged stdout and stderr to a log. + + Returns the exit status and the log path. A non-zero status is returned, + not raised: whether it is fatal is the backend's judgement, and NEO in + particular writes useful diagnostics alongside a failure. + """ + command = [str(executable), *[str(argument) for argument in arguments]] + log_path.parent.mkdir(parents=True, exist_ok=True) + # Opened outside the try so a bad log path stays its own error rather than + # being reported as an unlaunchable solver. + with log_path.open("w", encoding="utf-8") as log: + try: + completed = subprocess.run( + command, + cwd=str(cwd), + env=gacode_environment(config, code), + stdout=log, + stderr=subprocess.STDOUT, + text=True, + timeout=None if config is None else config.timeout, + check=False, + ) + except OSError as error: + raise ExecutableNotLaunchable( + f"cannot launch {executable}: {error}" + ) from error + return int(completed.returncode), log_path diff --git a/vaft/code/gacode/_types.py b/vaft/code/gacode/_types.py new file mode 100644 index 00000000..6a48046f --- /dev/null +++ b/vaft/code/gacode/_types.py @@ -0,0 +1,76 @@ +"""Dataclasses and constants shared by every GACODE-suite backend. + +GACODE is a suite -- NEO, TGLF, CGYRO and their tools share one source tree, +one build and one profile format -- so the runtime contract lives here, at the +suite level, and each backend adds only what is specific to it. That is the +same shape ``vaft.code.gpec`` uses for DCON/RDCON/STRIDE/GPEC, and it is why +this is not a top-level ``vaft.code.neo``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from ..base import CodeConfig + +#: The VAFT-side installation root, matching $GPECHOME, $CHEASEHOME and friends. +GACODE_HOME_ENV = "GACODEHOME" + +#: GACODE's own root variable. VAFT reads it as a fallback and *sets* it for +#: the subprocess, but never redefines what it means: a tree configured for a +#: plain shell through ``shared/bin/gacode_setup`` keeps working unchanged. +GACODE_ROOT_ENV = "GACODE_ROOT" + +#: Selects ``platform/build/make.inc.$GACODE_PLATFORM`` at build time and +#: ``platform/exec/exec.$GACODE_PLATFORM`` at run time. There is no default +#: that is right anywhere, so it is resolved explicitly and never guessed. +GACODE_PLATFORM_ENV = "GACODE_PLATFORM" + +GACODE_COMPATIBILITY_ENVS: tuple[str, ...] = (GACODE_ROOT_ENV,) + +#: Suite members that build from this tree. Only ``neo`` has a VAFT adapter +#: today; the rest are listed because the runtime resolves any of them and +#: because issue #553 adds TGLF next. +SUITE_CODES: tuple[str, ...] = ("neo", "tglf", "cgyro") + +#: Backends VAFT can actually prepare, run and parse. +SUPPORTED_CODES = frozenset({"neo"}) + + +@dataclass(frozen=True) +class GACODEConfig(CodeConfig): + """Runtime configuration shared by every GACODE backend. + + Subclasses :class:`vaft.code.base.CodeConfig`, so ``executable``, + ``workdir``, ``args``, ``env`` and ``timeout`` mean what they mean + everywhere else in :mod:`vaft.code`. ``executable`` overrides the launcher + path outright and is the escape hatch for an installation this resolution + order does not describe. + + Attributes + ---------- + home : str, optional + Installation root. Falls back to ``$GACODEHOME``, then to + ``$GACODE_ROOT``. + platform : str, optional + Platform tag. Falls back to ``$GACODE_PLATFORM``. A wrong value fails + inside a shell script without naming itself, which is why + :func:`vaft.code.gacode.gacode_platform` resolves it up front and lists + what the installation actually provides. + n_mpi : int + MPI tasks passed to the launcher's ``-n``. + n_omp : int + OpenMP threads passed to the launcher's ``-nomp``. + """ + + home: Optional[str] = None + platform: Optional[str] = None + n_mpi: int = 1 + n_omp: int = 1 + + def __post_init__(self) -> None: + if int(self.n_mpi) < 1: + raise ValueError(f"n_mpi must be at least 1; got {self.n_mpi!r}") + if int(self.n_omp) < 1: + raise ValueError(f"n_omp must be at least 1; got {self.n_omp!r}") diff --git a/vaft/code/gacode/inputs.py b/vaft/code/gacode/inputs.py new file mode 100644 index 00000000..95c7b4bf --- /dev/null +++ b/vaft/code/gacode/inputs.py @@ -0,0 +1,649 @@ +"""Convert an IMAS/OMAS scientific state into a GACODE profile set. + +The direction is one-way and deliberate:: + + equilibrium + core_profiles canonical, IMAS + | + v + GACODEProfile neutral, typed, provenance-bearing + | + v + input.gacode one external suite's format + +The canonical state is never replaced by the GACODE one. What this module owns +is the conversion, and three decisions it refuses to make silently: + +**The radial coordinate.** GACODE's ``rho`` is ``sqrt(Phi/Phi_boundary)``. +Several packaged VAFT equilibria store ``sqrt(psi_N)`` under the name +``rho_tor_norm`` (issues #276, #420), and the two agree only for a flat-q +cylinder. The grid is checked with :func:`vaft.data._derived.is_rho_pol_proxy`, +re-derived from ``q`` and ``psi`` when it is a proxy, and the conversion is +refused when it cannot be re-derived. A GACODE file written on +``sqrt(psi_N)`` and labelled ``rho`` is the exact defect those issues exist to +prevent. + +**Time alignment.** The requested time, the equilibrium slice actually used and +the ``core_profiles`` slice actually used are resolved separately, all three are +recorded, and a pairing outside the tolerance is refused rather than made. + +**The sign convention.** VAFT holds COCOS 11 internally; ``input.gacode`` is +COCOS 2 in its signs (see the ``gacode`` entry in :mod:`vaft.data.cocos`), so +the toroidal field, the current, ``fpol``, the toroidal flux, the toroidal +velocity and the poloidal flux all change sign on the way out, and ``q`` does +not. The factors come from :func:`omas.omas_physics.cocos_transform` rather +than being written out here, and the conversion is recorded in provenance. +Skipping it does not change the bootstrap current -- flipping the field and the +current together preserves the helicity -- but NEO reads the field directions +from these signs, so every lab-frame direction it reports would be mirrored. + +**Missing kinetic information.** Nothing is fabricated to fill a GACODE field. +A required quantity that is absent raises; an optional one that is absent stays +``None`` and is recorded as ``unavailable``; a value that comes from machine +policy rather than measurement is recorded as ``policy_assumption``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Optional, Sequence + +import numpy as np + +from vaft.data.cocos import VAFT_INTERNAL_COCOS + +from ..base import CodeInputs +from ._input_gacode import write_input_gacode +from ._profiles import GACODEProfile + +#: Smallest tolerance used when pairing an equilibrium slice with a +#: core_profiles slice, matching `vaft.validation.equilibrium`. +MINIMUM_TIME_TOLERANCE = 1.0e-3 + +#: GACODE stores densities in 10^19 m^-3 and temperatures in keV. +DENSITY_SCALE = 1.0e19 +TEMPERATURE_SCALE = 1.0e3 + + +class ProfileConversionError(ValueError): + """The canonical state cannot be projected onto a GACODE profile set. + + Distinct from a plain ``ValueError`` so a caller can tell "this shot cannot + be modelled as it stands" from a programming mistake, and act on it -- by + truncating the grid, supplying a species assumption, or choosing another + time -- rather than by working around the adapter. + """ + + +@dataclass +class GACODEInputs(CodeInputs): + """A staged GACODE case: the typed profile and the file written from it.""" + + profile: Optional[GACODEProfile] = None + input_gacode: Optional[Path] = None + provenance: Mapping[str, Any] = field(default_factory=dict) + + +def _array(ods: Any, path: str) -> Optional[np.ndarray]: + """Read a path without materialising it when it is absent. + + ``ods["missing.path"]`` *creates* the path in OMAS, so every read here is + guarded by a membership test first. + """ + try: + if path not in ods: + return None + value = ods[path] + except (KeyError, ValueError, IndexError, TypeError): + return None + array = np.asarray(value, dtype=float) + return array if array.size else None + + +def _scalar(ods: Any, path: str) -> Optional[float]: + array = _array(ods, path) + if array is None: + return None + return float(np.atleast_1d(array)[0]) + + +def _time_tolerance(times: np.ndarray) -> float: + """Half the median sampling interval, floored. + + The same rule `vaft.validation.equilibrium` uses, so that "these two slices + describe the same instant" means one thing across VAFT. + """ + if times is None or times.size < 2: + return MINIMUM_TIME_TOLERANCE + return max(0.5 * float(np.median(np.diff(np.sort(times)))), MINIMUM_TIME_TOLERANCE) + + +def _nearest(times: np.ndarray, target: float) -> tuple[int, float]: + offsets = np.abs(times - target) + index = int(np.argmin(offsets)) + return index, float(offsets[index]) + + +def _resolve_times( + ods: Any, + *, + time: Optional[float], + time_index: Optional[int], + tolerance: Optional[float], +) -> dict[str, Any]: + """Resolve, and record, which slices this conversion actually used.""" + equilibrium_times = _array(ods, "equilibrium.time") + if equilibrium_times is None: + raise ProfileConversionError("the ODS has no equilibrium.time") + profile_times = _array(ods, "core_profiles.time") + if profile_times is None: + raise ProfileConversionError("the ODS has no core_profiles.time") + + if time_index is not None: + if not 0 <= int(time_index) < equilibrium_times.size: + raise ProfileConversionError( + f"time_index {time_index} is outside the {equilibrium_times.size} " + "equilibrium slices" + ) + equilibrium_index = int(time_index) + requested = float(equilibrium_times[equilibrium_index]) + elif time is not None: + requested = float(time) + equilibrium_index, offset = _nearest(equilibrium_times, requested) + limit = tolerance if tolerance is not None else _time_tolerance(equilibrium_times) + if offset > limit: + raise ProfileConversionError( + f"the nearest equilibrium slice is {offset:.4g} s from the requested " + f"{requested:.4g} s, beyond the {limit:.4g} s tolerance" + ) + elif equilibrium_times.size == 1: + equilibrium_index = 0 + requested = float(equilibrium_times[0]) + else: + raise ProfileConversionError( + f"the ODS has {equilibrium_times.size} equilibrium slices; pass time= or " + "time_index= rather than letting the conversion choose one" + ) + + equilibrium_time = float(equilibrium_times[equilibrium_index]) + limit = tolerance if tolerance is not None else _time_tolerance(profile_times) + profile_index, offset = _nearest(profile_times, equilibrium_time) + if offset > limit: + raise ProfileConversionError( + f"the nearest core_profiles slice is {offset:.4g} s from the equilibrium " + f"slice at {equilibrium_time:.4g} s, beyond the {limit:.4g} s tolerance. " + "Profile and equilibrium slices are not combined outside it." + ) + return { + "requested_time": requested, + "equilibrium_index": equilibrium_index, + "equilibrium_time": equilibrium_time, + "core_profiles_index": profile_index, + "core_profiles_time": float(profile_times[profile_index]), + "tolerance": float(limit), + } + + +def _resolve_rho(ods: Any, prefix: str) -> tuple[np.ndarray, str]: + """Return the GACODE radial coordinate and how it was obtained. + + Refuses rather than substituting: a `sqrt(psi_N)` proxy that cannot be + re-derived into a real toroidal coordinate stops the conversion. + """ + from vaft.data._derived import is_rho_pol_proxy, rho_tor_profile + from vaft.data.eqdsk import ods_psi_to_wb_per_radian_factor + + rho = _array(ods, f"{prefix}.rho_tor_norm") + psi = _array(ods, f"{prefix}.psi") + q = _array(ods, f"{prefix}.q") + + psi_norm = None + if psi is not None and psi.size > 1 and psi[-1] != psi[0]: + psi_norm = (psi - psi[0]) / (psi[-1] - psi[0]) + + if rho is not None and not is_rho_pol_proxy(rho, psi_norm): + return rho, "equilibrium.profiles_1d.rho_tor_norm" + + phi = _array(ods, f"{prefix}.phi") + if phi is not None and phi.size > 1 and float(phi[-1]) != 0.0: + return np.sqrt(np.abs(phi / phi[-1])), "derived from equilibrium phi" + + if q is not None and psi is not None: + # rho_tor_profile wants psi in full weber: stored -> Wb/rad -> Wb. + factor = ods_psi_to_wb_per_radian_factor(ods) + derived = rho_tor_profile(q, psi * factor * 2.0 * np.pi) + if derived is not None: + return np.asarray(derived.rho_tor_norm, dtype=float), "derived from q and psi" + + if rho is not None: + raise ProfileConversionError( + "equilibrium rho_tor_norm is the sqrt(psi_N) proxy (issues #276, #420) and " + "no toroidal flux is available to re-derive it. GACODE's rho is " + "sqrt(Phi/Phi_boundary); writing sqrt(psi_N) under that name would be wrong, " + "so the conversion stops here." + ) + raise ProfileConversionError( + "the equilibrium carries no rho_tor_norm, phi, or usable q and psi, so the " + "GACODE radial coordinate cannot be established" + ) + + +def _interpolate(source_rho: np.ndarray, values: np.ndarray, target_rho: np.ndarray) -> np.ndarray: + """Map a profile onto the GACODE grid, refusing to extrapolate. + + ``np.interp`` clamps outside the source range, which silently invents an + edge value. The range is checked first so that a profile that does not cover + the equilibrium grid is reported instead. + """ + if source_rho.size != values.size: + raise ProfileConversionError( + f"a core_profiles quantity has {values.size} points against a " + f"{source_rho.size}-point grid" + ) + if source_rho.size == target_rho.size and np.allclose(source_rho, target_rho): + return np.asarray(values, dtype=float) + lower, upper = float(np.min(source_rho)), float(np.max(source_rho)) + if float(np.min(target_rho)) < lower - 1e-9 or float(np.max(target_rho)) > upper + 1e-9: + raise ProfileConversionError( + f"the kinetic profiles span rho [{lower:.4g}, {upper:.4g}] but the " + f"equilibrium grid spans [{float(np.min(target_rho)):.4g}, " + f"{float(np.max(target_rho)):.4g}]; extrapolating kinetic data onto an " + "equilibrium grid it does not cover is not done here" + ) + order = np.argsort(source_rho) + return np.interp(target_rho, np.asarray(source_rho)[order], np.asarray(values)[order]) + + +def _ion_species(ods: Any, index: int) -> list[dict[str, Any]]: + """Read the ion species table from core_profiles.""" + prefix = f"core_profiles.profiles_1d.{index}.ion" + species: list[dict[str, Any]] = [] + position = 0 + while True: + base = f"{prefix}.{position}" + try: + present = f"{base}.label" in ods or f"{base}.z_ion" in ods + except (KeyError, ValueError, TypeError): + present = False + if not present: + break + label = str(ods[f"{base}.label"]) if f"{base}.label" in ods else f"ion{position}" + charge = _scalar(ods, f"{base}.z_ion") + mass = _scalar(ods, f"{base}.element.0.a") + density = _array(ods, f"{base}.density_thermal") + if density is None: + density = _array(ods, f"{base}.density") + species.append( + { + "label": label, + "z": 1.0 if charge is None else float(charge), + "mass": mass, + "density": density, + "temperature": _array(ods, f"{base}.temperature"), + "velocity_toroidal": _array(ods, f"{base}.velocity.toroidal"), + } + ) + position += 1 + return species + + +def _require_positive(name: str, values: np.ndarray) -> None: + array = np.asarray(values, dtype=float) + if not np.all(np.isfinite(array)): + raise ProfileConversionError(f"{name} contains non-finite values") + if np.any(array <= 0.0): + bad = int(np.argmax(array <= 0.0)) + raise ProfileConversionError( + f"{name} is not positive at grid point {bad} (value {array[bad]:.6g}). " + "GACODE takes logarithmic gradients of densities and temperatures, so a " + "zero or negative value is not usable. Truncate the grid with rho_max=, or " + "supply a fit that stays positive; nothing is clipped here." + ) + + +def prepare_gacode_profile( + ods: Any, + *, + time: Optional[float] = None, + time_index: Optional[int] = None, + tolerance: Optional[float] = None, + rho_max: Optional[float] = None, + z_eff: Optional[float] = None, + shot: Optional[int] = None, +) -> GACODEProfile: + """Project an ODS equilibrium and core_profiles onto a GACODE profile set. + + Parameters + ---------- + ods + An OMAS ODS carrying ``equilibrium`` and ``core_profiles``. + time, time_index + Which slice to convert. ``time_index`` indexes the equilibrium + directly; ``time`` snaps to the nearest slice within the tolerance. With + a single equilibrium slice, neither is needed. + tolerance + Seconds within which an equilibrium slice and a core_profiles slice are + taken to describe the same instant. Defaults to half the median + sampling interval, floored at one millisecond. + rho_max + Truncate the GACODE grid at this normalised radius. This is the + caller's explicit decision about an edge region the profiles do not + support, and it is recorded in provenance. + z_eff + A single effective charge to use when the ODS carries no Z_eff and no + impurity species. Recorded as ``caller_supplied``. + shot + Shot number for the file header; read from ``dataset_description`` when + omitted. + + Raises + ------ + ProfileConversionError + The state cannot be projected: an unusable radial coordinate, slices + that cannot be paired, kinetic profiles that do not cover the + equilibrium grid, or a non-positive density or temperature. + """ + times = _resolve_times(ods, time=time, time_index=time_index, tolerance=tolerance) + equilibrium_prefix = ( + f"equilibrium.time_slice.{times['equilibrium_index']}.profiles_1d" + ) + global_prefix = ( + f"equilibrium.time_slice.{times['equilibrium_index']}.global_quantities" + ) + profile_index = times["core_profiles_index"] + profile_prefix = f"core_profiles.profiles_1d.{profile_index}" + + rho, rho_source = _resolve_rho(ods, equilibrium_prefix) + provenance: dict[str, dict[str, Any]] = { + "rho": {"kind": "derived", "source": rho_source}, + "time": {"kind": "derived", "source": "slice resolution", **times}, + } + + keep = np.ones(rho.size, dtype=bool) + if rho_max is not None: + keep = rho <= float(rho_max) + 1e-12 + if int(np.count_nonzero(keep)) < 2: + raise ProfileConversionError( + f"rho_max={rho_max} leaves fewer than two grid points" + ) + provenance["rho_max"] = { + "kind": "caller_supplied", + "value": float(rho_max), + "points_dropped": int(rho.size - np.count_nonzero(keep)), + } + rho = rho[keep] + + def equilibrium_profile(name: str) -> Optional[np.ndarray]: + values = _array(ods, f"{equilibrium_prefix}.{name}") + return None if values is None else values[keep] + + r_inboard = equilibrium_profile("r_inboard") + r_outboard = equilibrium_profile("r_outboard") + if r_inboard is not None and r_outboard is not None: + rmin = 0.5 * (r_outboard - r_inboard) + rmaj = 0.5 * (r_outboard + r_inboard) + provenance["rmin"] = {"kind": "derived", "source": "r_outboard, r_inboard"} + provenance["rmaj"] = {"kind": "derived", "source": "r_outboard, r_inboard"} + else: + rmin = rmaj = None + provenance["rmin"] = provenance["rmaj"] = { + "kind": "unavailable", + "reason": "the equilibrium carries no r_inboard/r_outboard", + } + + upper = equilibrium_profile("triangularity_upper") + lower = equilibrium_profile("triangularity_lower") + if upper is not None and lower is not None: + delta = 0.5 * (upper + lower) + provenance["delta"] = { + "kind": "derived", + "source": "mean of triangularity_upper and triangularity_lower", + } + else: + delta = equilibrium_profile("triangularity") + provenance["delta"] = ( + {"kind": "derived", "source": "equilibrium triangularity"} + if delta is not None + else {"kind": "unavailable", "reason": "no triangularity on the equilibrium"} + ) + + # zeta is GACODE's squareness and the IMAS squareness_* family is defined + # per quadrant with a different sign convention; they are not the same + # number, so nothing is written rather than something close. + provenance["zeta"] = { + "kind": "unavailable", + "reason": "IMAS squareness_* is per-quadrant and is not GACODE's zeta", + } + + from vaft.data.eqdsk import ods_psi_to_wb_per_radian_factor + + sign = _cocos_factors() + provenance["cocos"] = { + "kind": "derived", + "from": VAFT_INTERNAL_COCOS, + "to": _gacode_cocos(), + "factors": {key: float(sign[key]) for key in ("PSI", "TOR", "BT", "IP", "F", "Q")}, + "confirmed": False, + "source": "vaft.data.cocos.convention_for('gacode')", + } + + psi_factor = ods_psi_to_wb_per_radian_factor(ods) + psi = equilibrium_profile("psi") + polflux = None + if psi is not None: + # stored -> full weber, then COCOS 11 -> 2, which also divides by 2*pi. + polflux = (psi - psi[0]) * psi_factor * 2.0 * np.pi * sign["PSI"] + provenance["polflux"] = { + "kind": "derived", + "source": "equilibrium psi, referenced to the axis", + "wb_per_radian_factor": float(psi_factor), + } + + # profiles_1d.phi is the full toroidal flux in weber whichever way psi is + # stored (vaft/data/eqdsk.py), so the psi storage factor must not touch it: + # applying it would write torfluxa 2*pi too large for a per-radian ODS. + # GACODE's torfluxa is per radian by its own definition -- expro derives + # B_unit as d(torfluxa rho^2)/d(r^2/2) -- which is where the 2*pi comes from. + # + # Read untruncated: rho stays normalised to the *plasma boundary* even when + # the grid is cut short, so Phi(rho) = torfluxa * rho^2 only holds if + # torfluxa is the boundary value. + phi_full = _array(ods, f"{equilibrium_prefix}.phi") + torfluxa = None + if phi_full is not None: + torfluxa = float(phi_full[-1]) / (2.0 * np.pi) * sign["TOR"] + provenance["torfluxa"] = { + "kind": "derived", + "source": "equilibrium phi at the plasma boundary, before any rho_max cut", + } + + # Kinetic profiles, mapped onto the equilibrium grid. + profile_rho = _array(ods, f"{profile_prefix}.grid.rho_tor_norm") + if profile_rho is None: + raise ProfileConversionError( + "core_profiles has no grid.rho_tor_norm, so its profiles cannot be placed " + "on the equilibrium's radial grid" + ) + n_e = _array(ods, f"{profile_prefix}.electrons.density_thermal") + if n_e is None: + n_e = _array(ods, f"{profile_prefix}.electrons.density") + t_e = _array(ods, f"{profile_prefix}.electrons.temperature") + if n_e is None or t_e is None: + raise ProfileConversionError( + "core_profiles carries no electron density or temperature; GACODE cannot be " + "run without them and nothing is substituted" + ) + ne = _interpolate(profile_rho, n_e, rho) + te = _interpolate(profile_rho, t_e, rho) + _require_positive("electron density", ne) + _require_positive("electron temperature", te) + provenance["ne"] = {"kind": "measured", "source": f"{profile_prefix}.electrons"} + provenance["te"] = {"kind": "measured", "source": f"{profile_prefix}.electrons"} + + species = _ion_species(ods, profile_index) + if not species: + raise ProfileConversionError( + "core_profiles carries no ion species; GACODE needs at least one and the " + "adapter does not invent a main ion" + ) + densities, temperatures, charges, masses, labels, kinds = [], [], [], [], [], [] + for entry in species: + if entry["density"] is None or entry["temperature"] is None: + raise ProfileConversionError( + f"ion species {entry['label']!r} has no density or no temperature" + ) + density = _interpolate(profile_rho, entry["density"], rho) / DENSITY_SCALE + temperature = _interpolate(profile_rho, entry["temperature"], rho) / TEMPERATURE_SCALE + _require_positive(f"{entry['label']} density", density) + _require_positive(f"{entry['label']} temperature", temperature) + densities.append(density) + temperatures.append(temperature) + charges.append(entry["z"]) + masses.append(entry["mass"] if entry["mass"] is not None else entry["z"] * 2.0) + labels.append(entry["label"].replace(" ", "")) + kinds.append("[therm]") + provenance["ni"] = { + "kind": "measured", + "source": f"{profile_prefix}.ion", + "species": labels, + } + + # Toroidal rotation is optional to GACODE and is only written when every + # species has it: a per-ion array with one species silently zeroed would + # claim a stationary impurity rather than an unmeasured one. + rotation = [entry["velocity_toroidal"] for entry in species] + if all(values is not None for values in rotation): + vtor = sign["TOR"] * np.vstack( + [_interpolate(profile_rho, values, rho) for values in rotation] + ) + provenance["vtor"] = { + "kind": "measured", + "source": f"{profile_prefix}.ion.:.velocity.toroidal", + } + else: + vtor = None + provenance["vtor"] = { + "kind": "unavailable", + "reason": "not every ion species carries velocity.toroidal", + } + if any(entry["mass"] is None for entry in species): + provenance["mass"] = { + "kind": "policy_assumption", + "reason": "an ion element mass was absent; 2Z amu assumed for it", + } + + effective_charge = _array(ods, f"{profile_prefix}.zeff") + if effective_charge is not None: + z_eff_profile = _interpolate(profile_rho, effective_charge, rho) + provenance["z_eff"] = {"kind": "measured", "source": f"{profile_prefix}.zeff"} + elif z_eff is not None: + z_eff_profile = np.full(rho.size, float(z_eff)) + provenance["z_eff"] = {"kind": "caller_supplied", "value": float(z_eff)} + elif len(charges) > 1: + stacked = np.vstack(densities) + z_eff_profile = ( + np.sum(stacked * np.asarray(charges)[:, None] ** 2, axis=0) + / (ne / DENSITY_SCALE) + ) + provenance["z_eff"] = { + "kind": "derived", + "source": "quasi-neutral sum over the ion species present", + } + else: + z_eff_profile = None + provenance["z_eff"] = { + "kind": "unavailable", + "reason": "one ion species, no zeff profile, and no z_eff= supplied", + } + + current = _scalar(ods, f"{global_prefix}.ip") + # b0 is sampled on the equilibrium time base, and VEST's drifts by up to a + # factor of two within a shot (#325), so it is read at the converted slice + # rather than at index 0. + field = _array(ods, "equilibrium.vacuum_toroidal_field.b0") + b0 = None + if field is not None: + field = np.atleast_1d(field) + b0 = float(field[min(times["equilibrium_index"], field.size - 1)]) + q_profile = equilibrium_profile("q") + f_profile = equilibrium_profile("f") + profile = GACODEProfile( + rho=rho, + z=np.asarray(charges, dtype=float), + mass=np.asarray(masses, dtype=float), + name=tuple(labels), + type=tuple(kinds), + rmin=rmin, + rmaj=rmaj, + zmag=_array(ods, f"{equilibrium_prefix}.geometric_axis.z"), + kappa=equilibrium_profile("elongation"), + delta=delta, + polflux=polflux, + q=None if q_profile is None else q_profile * sign["Q"], + ptot=equilibrium_profile("pressure"), + fpol=None if f_profile is None else f_profile * sign["F"], + torfluxa=torfluxa, + rcentr=_scalar(ods, "equilibrium.vacuum_toroidal_field.r0"), + bcentr=None if b0 is None else b0 * sign["BT"], + current=None if current is None else current / 1.0e6 * sign["IP"], + ne=ne / DENSITY_SCALE, + te=te / TEMPERATURE_SCALE, + ni=np.vstack(densities), + ti=np.vstack(temperatures), + z_eff=z_eff_profile, + vtor=vtor, + shot=shot if shot is not None else _shot_number(ods), + time=int(round(times["equilibrium_time"] * 1.0e3)), + header={ + "original": "vaft.code.gacode.inputs.prepare_gacode_profile", + "statefile": "IMAS core_profiles", + "gfile": "IMAS equilibrium", + }, + ) + if profile.zmag is not None: + profile.zmag = np.asarray(profile.zmag, dtype=float)[keep] + profile.provenance = provenance + return profile + + +def _gacode_cocos() -> int: + from vaft.data.cocos import convention_for + + return int(convention_for("gacode").cocos) + + +def _cocos_factors() -> Mapping[str, float]: + """Multipliers taking VAFT's COCOS 11 quantities into input.gacode's convention.""" + from omas.omas_physics import cocos_transform + + return cocos_transform(VAFT_INTERNAL_COCOS, _gacode_cocos()) + + +def _shot_number(ods: Any) -> Optional[int]: + value = _scalar(ods, "dataset_description.data_entry.pulse") + return None if value is None else int(value) + + +def prepare_gacode_inputs( + ods: Any, + workdir: str | Path, + **kwargs: Any, +) -> GACODEInputs: + """Convert an ODS and stage ``input.gacode`` in *workdir*. + + Keyword arguments are those of :func:`prepare_gacode_profile`. The working + directory is the caller's: nothing here uses a temporary directory, so a run + stays inspectable after it finishes. + """ + directory = Path(workdir) + directory.mkdir(parents=True, exist_ok=True) + profile = prepare_gacode_profile(ods, **kwargs) + written = write_input_gacode(profile, directory / "input.gacode") + return GACODEInputs( + workdir=directory, + files=(written,), + ods=ods, + profile=profile, + input_gacode=written, + provenance=dict(profile.provenance), + ) diff --git a/vaft/code/gacode/neo/__init__.py b/vaft/code/gacode/neo/__init__.py new file mode 100644 index 00000000..376e51e5 --- /dev/null +++ b/vaft/code/gacode/neo/__init__.py @@ -0,0 +1,52 @@ +"""NEO: the GACODE suite's drift-kinetic neoclassical solver. + + GACODEProfile ---> input.gacode + input.neo ---> NEO ---> NeoOutputs + | + solver-native source of truth + +``NeoOutputs`` is the whole run, in NEO's own units and on NEO's grid. It is +deliberately *not* an IDS: the audit of which quantities have a defensible +IMAS home is phase 5 of issue #550, and until it is done a mapping would be +name-matching rather than physics. + +The VAFT-native analytic counterpart is :mod:`vaft.formula.neoclassical`, kept +a separate computational identity on purpose: Sauter and Redl answer a related +but different question from a drift-kinetic solve, and hiding both behind one +``model=`` switch would conceal that. +""" + +from __future__ import annotations + +from ._types import NEO_DEFAULTS, NEOConfig, NEOResult +from .inputs import NEOInputs, neo_parameters, prepare_neo_case, write_input_neo +from .outputs import ( + SCHEMA, + SCHEMA_VERSION, + THEORY_SCALARS, + NeoGrid, + NeoNormalisation, + NeoOutputs, + collect_neo_outputs, +) +from .runner import NEOExecutionError, read_neo_case, run_neo, run_neo_case + +__all__ = [ + "NEOConfig", + "NEOExecutionError", + "NEOInputs", + "NEOResult", + "NEO_DEFAULTS", + "NeoGrid", + "NeoNormalisation", + "NeoOutputs", + "SCHEMA", + "SCHEMA_VERSION", + "THEORY_SCALARS", + "collect_neo_outputs", + "neo_parameters", + "prepare_neo_case", + "read_neo_case", + "run_neo", + "run_neo_case", + "write_input_neo", +] diff --git a/vaft/code/gacode/neo/_types.py b/vaft/code/gacode/neo/_types.py new file mode 100644 index 00000000..917a82dc --- /dev/null +++ b/vaft/code/gacode/neo/_types.py @@ -0,0 +1,128 @@ +"""Configuration and result types for the NEO backend.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional + +from ...base import CodeResult +from .._types import GACODEConfig + +#: NEO's own defaults, from `neo/bin/neo_parse.py`. Repeated here rather than +#: left implicit because a run whose resolution was never chosen is not +#: reproducible: `input.neo` records only what it is given, and NEO fills the +#: rest silently. +NEO_DEFAULTS: Mapping[str, Any] = { + "N_ENERGY": 6, + "N_XI": 17, + "N_THETA": 17, + "N_RADIAL": 1, + "RMIN_OVER_A": 0.5, + "COLLISION_MODEL": 4, + "PROFILE_MODEL": 2, + "PROFILE_ERAD0_MODEL": 1, + "ROTATION_MODEL": 1, + "SPITZER_MODEL": 0, + "EQUILIBRIUM_MODEL": 0, + "SILENT_FLAG": 0, + "IPCCW": -1, + "BTCCW": -1, +} + +#: `PROFILE_MODEL=2` is the mode that reads `input.gacode`; `1` is the local +#: mode where every profile quantity comes from `input.neo` itself. +PROFILE_MODEL_EXPERIMENTAL = 2 + + +@dataclass(frozen=True) +class NEOConfig(GACODEConfig): + """A NEO run's numerical settings, on top of the shared GACODE runtime. + + Every field lands in `input.neo` verbatim and is carried into the result's + provenance, so a stored result says what produced it. + + Attributes + ---------- + n_energy, n_xi, n_theta + Velocity-space and poloidal resolution. NEO does not estimate its own + discretisation error, so a convergence scan is the caller's job and + these are the knobs for it. + n_radial + Number of radial points solved. With ``PROFILE_MODEL=2`` these are + placed relative to ``rmin_over_a``. + rmin_over_a + Normalised minor radius of the (first) surface to solve. + collision_model + 4 is the full linearised Fokker-Planck operator. + profile_model + 2 reads ``input.gacode``; 1 takes local parameters from ``input.neo``. + rotation_model + 1 ignores rotation; 2 includes the sonic-rotation terms. + n_species + Total species count, electrons included. Defaults to the species in the + supplied profile. + extra_parameters + Additional ``KEY=VALUE`` pairs written verbatim, for NEO settings this + class does not model. They are recorded in provenance like any other. + """ + + n_energy: int = 6 + n_xi: int = 17 + n_theta: int = 17 + n_radial: int = 1 + rmin_over_a: float = 0.5 + collision_model: int = 4 + profile_model: int = PROFILE_MODEL_EXPERIMENTAL + profile_erad0_model: int = 1 + rotation_model: int = 1 + spitzer_model: int = 0 + equilibrium_model: int = 0 + ipccw: int = -1 + btccw: int = -1 + n_species: Optional[int] = None + extra_parameters: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__post_init__() + for name in ("n_energy", "n_xi", "n_theta", "n_radial"): + if int(getattr(self, name)) < 1: + raise ValueError(f"{name} must be at least 1; got {getattr(self, name)!r}") + if not 0.0 < float(self.rmin_over_a) < 1.0: + raise ValueError( + f"rmin_over_a must lie in (0, 1); got {self.rmin_over_a!r}. " + "NEO solves a flux surface, and neither the axis nor the " + "separatrix is one." + ) + # NEO's own limits (neo/src/neo_check.f90), refused here so that the + # message names the setting rather than arriving through out.neo.run. + if int(self.n_theta) % 2 == 0: + raise ValueError(f"n_theta must be odd for NEO; got {self.n_theta!r}") + if self.n_species is not None and int(self.n_species) > 6: + raise ValueError(f"NEO supports at most 6 species; got {self.n_species!r}") + if self.n_species is not None and int(self.n_species) < 2: + raise ValueError( + f"n_species counts electrons too, so it is at least 2; got " + f"{self.n_species!r}" + ) + + +@dataclass +class NEOResult(CodeResult): + """A NEO run: its exit status, its files, and its native output. + + Subclasses :class:`vaft.code.base.CodeResult`, and follows NUBEAM in + requiring the native container for ``ok`` -- and goes further, requiring it + to report a completed solve. NEO exits zero after rejecting its input, so a + zero status alone is not success, and nor is the presence of output files. + """ + + outputs_native: Optional[Any] = None + provenance: Mapping[str, Any] = field(default_factory=dict) + + @property + def ok(self) -> bool: + return ( + self.returncode == 0 + and self.outputs_native is not None + and self.outputs_native.solved + ) diff --git a/vaft/code/gacode/neo/inputs.py b/vaft/code/gacode/neo/inputs.py new file mode 100644 index 00000000..e6aeb419 --- /dev/null +++ b/vaft/code/gacode/neo/inputs.py @@ -0,0 +1,132 @@ +"""Generate ``input.neo`` and stage a NEO case. + +Staging is not a user-facing step. ``prepare_neo_case`` takes the profile and +the configuration and leaves a directory NEO can be pointed at; the caller never +copies files by hand. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Optional + +from ...base import CodeInputs +from .._input_gacode import write_input_gacode +from .._profiles import GACODEProfile +from ._types import NEOConfig, PROFILE_MODEL_EXPERIMENTAL + + +@dataclass +class NEOInputs(CodeInputs): + """A staged NEO case: the directory, the files in it, and what made them.""" + + profile: Optional[GACODEProfile] = None + input_neo: Optional[Path] = None + input_gacode: Optional[Path] = None + parameters: Mapping[str, Any] = field(default_factory=dict) + provenance: Mapping[str, Any] = field(default_factory=dict) + + +def neo_parameters( + config: NEOConfig, profile: Optional[GACODEProfile] = None +) -> dict[str, Any]: + """The ``KEY=VALUE`` settings this configuration means. + + Written out in full rather than relying on NEO's defaults: `input.neo` + records only what it is given, so a file that omits a setting cannot be told + apart later from one that chose NEO's default deliberately. + """ + species = config.n_species + if species is None: + if profile is None: + raise ValueError( + "n_species is not set and no profile was given, so the species count " + "cannot be established; NEO counts electrons too" + ) + species = profile.n_ion + 1 + + parameters: dict[str, Any] = { + "N_ENERGY": int(config.n_energy), + "N_XI": int(config.n_xi), + "N_THETA": int(config.n_theta), + "N_RADIAL": int(config.n_radial), + "RMIN_OVER_A": float(config.rmin_over_a), + "SILENT_FLAG": 0, + "EQUILIBRIUM_MODEL": int(config.equilibrium_model), + "COLLISION_MODEL": int(config.collision_model), + "PROFILE_MODEL": int(config.profile_model), + "PROFILE_ERAD0_MODEL": int(config.profile_erad0_model), + "ROTATION_MODEL": int(config.rotation_model), + "SPITZER_MODEL": int(config.spitzer_model), + "IPCCW": int(config.ipccw), + "BTCCW": int(config.btccw), + "N_SPECIES": int(species), + } + parameters.update({str(k).upper(): v for k, v in config.extra_parameters.items()}) + return parameters + + +def write_input_neo(parameters: Mapping[str, Any], path: str | Path) -> Path: + """Write an ``input.neo`` file, one ``KEY=VALUE`` per line.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + lines = [f"{key}={_render(value)}" for key, value in parameters.items()] + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + return target + + +def _render(value: Any) -> str: + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, float): + return repr(float(value)) + return str(value) + + +def prepare_neo_case( + profile: GACODEProfile, + workdir: str | Path, + config: Optional[NEOConfig] = None, +) -> NEOInputs: + """Stage ``input.gacode`` and ``input.neo`` in *workdir*. + + The directory is the caller's and is not a temporary one, so a run stays + inspectable afterwards. + + Raises + ------ + ValueError + The profile lacks something NEO needs for the configured + ``PROFILE_MODEL``, or the configuration is internally inconsistent. + """ + configuration = config or NEOConfig() + directory = Path(workdir) + directory.mkdir(parents=True, exist_ok=True) + + if int(configuration.profile_model) >= PROFILE_MODEL_EXPERIMENTAL: + absent = profile.check_neo_requirements() + if absent: + raise ValueError( + f"PROFILE_MODEL={configuration.profile_model} reads input.gacode, but " + f"the profile is missing {', '.join(absent)}. Nothing is substituted; " + "supply them or use PROFILE_MODEL=1 with local parameters." + ) + + parameters = neo_parameters(configuration, profile) + input_gacode = write_input_gacode(profile, directory / "input.gacode") + input_neo = write_input_neo(parameters, directory / "input.neo") + return NEOInputs( + workdir=directory, + files=(input_gacode, input_neo), + profile=profile, + input_neo=input_neo, + input_gacode=input_gacode, + parameters=parameters, + provenance={ + "profile": dict(profile.provenance), + "n_exp": profile.n_exp, + "n_ion": profile.n_ion, + "species": tuple(profile.name), + }, + ) diff --git a/vaft/code/gacode/neo/outputs.py b/vaft/code/gacode/neo/outputs.py new file mode 100644 index 00000000..d1d5be2e --- /dev/null +++ b/vaft/code/gacode/neo/outputs.py @@ -0,0 +1,548 @@ +"""Parse a NEO run directory into a complete, solver-native result. + +Nothing here writes an IDS. Following the split `vaft/code/nubeam/outputs.py` +documents, the IDS-populating layer reads this container rather than re-parsing +solver output itself, and the container stays a faithful transcript of what NEO +produced -- in NEO's units, on NEO's grid, with NEO's sign conventions. The +audit that decides which of these quantities has a defensible IMAS home is +deliberately not done here (issue #550, phase 5). + +**Column layouts are taken from NEO's writers, not from `pygacode`.** +`pygacode/neo/data.py`'s `read_theory` is stale with respect to +`neo/src/neo_theory.f90`: it reads the per-species block as three-wide +(`HSGamma`, `HSQ`, `KjparB`), while the writer emits two values per species and +then two trailing scalars. For the three-species reg18 case that is 24 columns +against the 23 the file actually has. The layout used here is the writer's, and +it is checked against stored runs with two *and* three species. + +**Exit status is not success.** NEO reports an input or physics error by +writing it to ``out.neo.run`` and then exiting **zero** (``neo_error`` sets a +flag and ``neo_do`` jumps to cleanup), and the launcher creates ``out.neo.run`` +and ``out.neo.version`` before NEO starts. So the presence of output files says +nothing; :attr:`NeoOutputs.errors` and :attr:`NeoOutputs.solved` are what do. + +**Absent is not zero.** NEO writes `out.neo.expnorm` and `out.neo.exprhon` only +when `PROFILE_MODEL >= 2`, and several files only under a rotation model. A +missing file leaves its field ``None``; it is never filled with zeros, because a +zero flux is a physical result and must stay distinguishable from an +unevaluated one. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, fields +import json +from pathlib import Path +from typing import Any, Mapping, Optional + +import numpy as np + +#: Bumped when the stored shape changes incompatibly; `from_dict` refuses a +#: payload written by a newer version rather than silently misreading it. +SCHEMA = "vaft.code.gacode.neo.NeoOutputs" +SCHEMA_VERSION = 1 + +#: The analytic models NEO evaluates alongside its own solve, in the order +#: `neo/src/neo_theory.f90` writes them. Every one is a comparison point, and +#: two of them -- `sauter_bootstrap_current` and `redl_bootstrap_current` -- are +#: what `vaft.formula.neoclassical` is verified against. +THEORY_SCALARS = ( + "hinton_hazeltine_particle_flux", + "hinton_hazeltine_ion_energy_flux", + "hinton_hazeltine_electron_energy_flux", + "hinton_hazeltine_bootstrap_current", + "hinton_hazeltine_k", + "hinton_hazeltine_uparB", + "hinton_hazeltine_poloidal_velocity", + "chang_hinton_ion_energy_flux", + "taguchi_ion_energy_flux", + "sauter_bootstrap_current", + "sauter_k", + "sauter_uparB", + "sauter_poloidal_velocity", + "hinton_rosenbluth_potential_squared", +) + +#: What each transported quantity means, and in what normalisation. From the +#: declarations in `neo/src/neo_transport.f90`. +QUANTITY_DESCRIPTIONS: Mapping[str, str] = { + "particle_flux": "Gamma / (n_0 v_t0), per species", + "energy_flux": "Q / (n_0 v_t0 T_0), per species", + "momentum_flux": "Pi / (n_0 a T_0), per species", + "uparB": " / (v_t0 B_0), per species", + "k": "poloidal-flow coefficient, per species", + "K": " n / (v_t0 B_0 n_0), per species", + "poloidal_velocity": "v_theta / v_t0, per species", + "toroidal_velocity": "v_phi / v_t0, per species", + "bootstrap_current": ", normalised", + "potential_squared": ", normalised", +} + +_TRANSPORT_PER_SPECIES = ( + "particle_flux", "energy_flux", "momentum_flux", "uparB", "k", "K", + "poloidal_velocity", "toroidal_velocity", +) + + +def _load(path: Path) -> Optional[np.ndarray]: + """Read a whitespace table, returning None when the file is absent or empty.""" + if not path.is_file(): + return None + try: + array = np.loadtxt(path) + except (ValueError, OSError): + return None + return array if array.size else None + + +def _rows(array: np.ndarray) -> np.ndarray: + """Present a table as two-dimensional, even for a single radial point.""" + return array[None, :] if array.ndim == 1 else array + + +def _check_columns(table: np.ndarray, expected: int, name: str, n_species: int) -> None: + """Refuse a table whose width disagrees with the species count. + + Every per-species block here is read by striding, and a stride over a table + of the wrong width does not fail -- it silently assigns one species' flux to + another. Checking the width is what makes that impossible. + """ + if table.shape[1] != expected: + raise ValueError( + f"{name} has {table.shape[1]} columns but {n_species} species imply " + f"{expected}; refusing to stride over it rather than mis-assign species" + ) + + +@dataclass +class NeoGrid: + """The discretisation NEO actually used.""" + + n_species: int + n_energy: int + n_xi: int + n_theta: int + theta: np.ndarray + n_radial: int + r_over_a: np.ndarray + + +@dataclass +class NeoNormalisation: + """`out.neo.expnorm`: everything needed to put NEO's output into SI. + + Written only for ``PROFILE_MODEL >= 2``, so it is absent from a purely + local run -- and without it the normalised outputs cannot be dimensionalised + at all, which is why its absence is recorded rather than worked around. + """ + + r_over_a: np.ndarray + a_meters: np.ndarray + mass_deuterium: np.ndarray + density_norm: np.ndarray + temperature_norm: np.ndarray + velocity_norm_times_a: np.ndarray + b_unit: np.ndarray + + +@dataclass +class NeoOutputs: + """One NEO run, in NEO's own terms. + + Attributes + ---------- + theory + The analytic models NEO evaluates for comparison, keyed by + :data:`THEORY_SCALARS` plus ``nclass_bootstrap_current`` and + ``redl_bootstrap_current``, each shaped ``(n_radial,)``. + coordinates + ``out.neo.exprhon``: the bridge from NEO's ``r/a`` back to + ``rho_tor_norm`` and ``psi_norm``, and so back into IMAS. + """ + + directory: str + grid: Optional[NeoGrid] = None + species_mass: Optional[np.ndarray] = None + species_charge: Optional[np.ndarray] = None + normalisation: Optional[NeoNormalisation] = None + coordinates: Optional[Mapping[str, np.ndarray]] = None + equilibrium: Optional[Mapping[str, np.ndarray]] = None + transport: Optional[Mapping[str, np.ndarray]] = None + transport_gyroviscous: Optional[Mapping[str, np.ndarray]] = None + transport_experimental: Optional[Mapping[str, np.ndarray]] = None + transport_gyrobohm: Optional[Mapping[str, np.ndarray]] = None + theory: Optional[Mapping[str, np.ndarray]] = None + rotation: Optional[Mapping[str, np.ndarray]] = None + geometry: Optional[Mapping[str, float]] = None + precision: Optional[float] = None + version: Optional[Mapping[str, str]] = None + errors: tuple[str, ...] = () + files: tuple[str, ...] = () + + @property + def n_species(self) -> Optional[int]: + if self.grid is not None: + return self.grid.n_species + if self.species_charge is not None: + return int(np.size(self.species_charge)) + return None + + @property + def bootstrap_current(self) -> Optional[np.ndarray]: + """NEO's own drift-kinetic ````, normalised.""" + if self.transport is None: + return None + return self.transport.get("bootstrap_current") + + @property + def trapped_fraction(self) -> Optional[float]: + """The trapped fraction NEO computed from the surface geometry. + + Integrated over the field-strength distribution, so it is the value to + prefer over + :func:`vaft.formula.neoclassical.trapped_particle_fraction`'s circular + approximation whenever a run is available. + """ + return None if self.geometry is None else self.geometry.get("f_trap") + + @property + def solved(self) -> bool: + """Whether NEO completed a solve, as opposed to merely leaving files. + + True only when NEO logged no error, wrote its transport product, and the + drift-kinetic current it wrote is finite. The last clause is not + pedantry: a degenerate geometry -- ``kappa`` absent from input.gacode, + which expro reads as zero -- produces NaN with no error logged. + """ + if self.errors or self.transport is None: + return False + current = self.bootstrap_current + return current is not None and bool(np.all(np.isfinite(current))) + + def describe(self, name: str) -> str: + """What a transported quantity means and how it is normalised.""" + try: + return QUANTITY_DESCRIPTIONS[name] + except KeyError: + raise KeyError( + f"{name!r} is not a NEO transport quantity; known names are " + f"{', '.join(sorted(QUANTITY_DESCRIPTIONS))}" + ) from None + + def missing(self) -> tuple[str, ...]: + """Products this run did not write, so absent never reads as zero.""" + return tuple( + f.name + for f in fields(self) + if f.name not in {"directory", "files", "errors"} and getattr(self, f.name) is None + ) + + # -- serialisation ---------------------------------------------------- + + def to_dict(self) -> dict[str, Any]: + """A JSON-ready payload that ``from_dict`` reads back exactly.""" + + def encode(value: Any) -> Any: + if isinstance(value, np.ndarray): + return {"__array__": value.tolist()} + if isinstance(value, (NeoGrid, NeoNormalisation)): + return { + "__record__": type(value).__name__, + "fields": {f.name: encode(getattr(value, f.name)) for f in fields(value)}, + } + if isinstance(value, Mapping): + return {key: encode(item) for key, item in value.items()} + if isinstance(value, tuple): + return [encode(item) for item in value] + if isinstance(value, (np.floating, np.integer)): + return value.item() + return value + + payload = {"schema": SCHEMA, "schema_version": SCHEMA_VERSION} + payload.update({f.name: encode(getattr(self, f.name)) for f in fields(self)}) + return payload + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "NeoOutputs": + version = int(payload.get("schema_version", 0)) + if version > SCHEMA_VERSION: + raise ValueError( + f"this payload is schema version {version} but this VAFT reads at most " + f"{SCHEMA_VERSION}; upgrade rather than reading it partially" + ) + + records = {"NeoGrid": NeoGrid, "NeoNormalisation": NeoNormalisation} + + def decode(value: Any) -> Any: + if isinstance(value, Mapping): + if "__array__" in value: + return np.asarray(value["__array__"]) + if "__record__" in value: + record = records[value["__record__"]] + return record(**{k: decode(v) for k, v in value["fields"].items()}) + return {key: decode(item) for key, item in value.items()} + return value + + known = {f.name for f in fields(cls)} + arguments = { + key: decode(value) for key, value in payload.items() if key in known + } + if isinstance(arguments.get("files"), list): + arguments["files"] = tuple(arguments["files"]) + return cls(**arguments) + + def write_json(self, path: str | Path) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(self.to_dict(), indent=1), encoding="utf-8") + return target + + @classmethod + def read_json(cls, path: str | Path) -> "NeoOutputs": + return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8"))) + + +def _parse_grid(directory: Path) -> Optional[NeoGrid]: + values = _load(directory / "out.neo.grid") + if values is None: + return None + flat = np.atleast_1d(values).ravel() + n_species, n_energy, n_xi, n_theta = (int(flat[i]) for i in range(4)) + theta = flat[4 : 4 + n_theta] + n_radial = int(flat[4 + n_theta]) + return NeoGrid( + n_species=n_species, + n_energy=n_energy, + n_xi=n_xi, + n_theta=n_theta, + theta=theta, + n_radial=n_radial, + r_over_a=flat[5 + n_theta : 5 + n_theta + n_radial], + ) + + +def _parse_equilibrium(directory: Path, n_species: int) -> Optional[Mapping[str, np.ndarray]]: + values = _load(directory / "out.neo.equil") + if values is None: + return None + table = _rows(values) + _check_columns(table, 7 + 5 * n_species, "out.neo.equil", n_species) + return { + "r_over_a": table[:, 0], + "dphi0dr": table[:, 1], + "q": table[:, 2], + "rho_star": table[:, 3], + "rmaj_over_a": table[:, 4], + "omega0": table[:, 5], + "domega0dr": table[:, 6], + "density": table[:, 7 + 0 :: 5].T, + "temperature": table[:, 7 + 1 :: 5].T, + "dlnndr": table[:, 7 + 2 :: 5].T, + "dlntdr": table[:, 7 + 3 :: 5].T, + "collision_rate": table[:, 7 + 4 :: 5].T, + } + + +def _parse_transport(path: Path, n_species: int) -> Optional[Mapping[str, np.ndarray]]: + """`out.neo.transport` and `out.neo.transport_exp` share a layout.""" + values = _load(path) + if values is None: + return None + table = _rows(values) + _check_columns( + table, 5 + len(_TRANSPORT_PER_SPECIES) * n_species, path.name, n_species + ) + parsed = { + "r_over_a": table[:, 0], + "potential_squared": table[:, 1], + "bootstrap_current": table[:, 2], + "poloidal_velocity_zeroth": table[:, 3], + "uparB_zeroth": table[:, 4], + } + for offset, name in enumerate(_TRANSPORT_PER_SPECIES): + parsed[name] = table[:, 5 + offset :: 8].T + return parsed + + +def _parse_transport_gv(path: Path, n_species: int) -> Optional[Mapping[str, np.ndarray]]: + values = _load(path) + if values is None: + return None + table = _rows(values) + _check_columns(table, 1 + 3 * n_species, path.name, n_species) + return { + "r_over_a": table[:, 0], + "particle_flux": table[:, 1 + 0 :: 3].T, + "energy_flux": table[:, 1 + 1 :: 3].T, + "momentum_flux": table[:, 1 + 2 :: 3].T, + } + + +def _parse_transport_flux(path: Path, n_species: int) -> Optional[Mapping[str, np.ndarray]]: + """`out.neo.transport_flux`: three blocks of n_species rows per radius.""" + values = _load(path) + if values is None or n_species < 1: + return None + table = _rows(values) + stride = 3 * n_species + if table.shape[0] % stride: + return None + blocks = ("drift_kinetic", "gyroviscous", "total") + parsed: dict[str, np.ndarray] = {} + for block_index, block in enumerate(blocks): + for column, name in enumerate(("particle_flux", "energy_flux", "momentum_flux"), start=1): + parsed[f"{block}_{name}"] = np.stack( + [table[block_index * n_species + s :: stride, column] for s in range(n_species)] + ) + return parsed + + +def _parse_theory(directory: Path, n_species: int) -> Optional[Mapping[str, np.ndarray]]: + values = _load(directory / "out.neo.theory") + if values is None: + return None + table = _rows(values) + expected = len(THEORY_SCALARS) + 1 + 2 * n_species + 2 + if table.shape[1] != expected: + raise ValueError( + f"out.neo.theory has {table.shape[1]} columns but {n_species} species imply " + f"{expected}. The layout is neo_theory.f90's THEORY_do, not pygacode's." + ) + parsed: dict[str, np.ndarray] = {"r_over_a": table[:, 0]} + for offset, name in enumerate(THEORY_SCALARS, start=1): + parsed[name] = table[:, offset] + base = 1 + len(THEORY_SCALARS) + parsed["hirshman_sigmar_particle_flux"] = table[:, base + 0 : base + 2 * n_species : 2].T + parsed["hirshman_sigmar_energy_flux"] = table[:, base + 1 : base + 2 * n_species : 2].T + parsed["nclass_bootstrap_current"] = table[:, base + 2 * n_species] + parsed["redl_bootstrap_current"] = table[:, base + 2 * n_species + 1] + return parsed + + +def _parse_geometry(directory: Path) -> Optional[Mapping[str, float]]: + path = directory / "out.neo.diagnostic_geo" + if not path.is_file(): + return None + parsed: dict[str, float] = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if not line.startswith("#") or "=" not in line: + continue + name, _, number = line[1:].partition("=") + try: + parsed[name.strip()] = float(number) + except ValueError: + continue + return parsed or None + + +def _parse_version(directory: Path) -> Optional[Mapping[str, str]]: + path = directory / "out.neo.version" + if not path.is_file(): + return None + lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + if not lines: + return None + keys = ("revision", "platform", "date") + return {key: value for key, value in zip(keys, lines)} + + +def _parse_errors(directory: Path) -> tuple[str, ...]: + """The error lines NEO wrote to out.neo.run, in order. + + `neo_error` writes each message verbatim, and every one NEO raises begins + with ``ERROR:``. + """ + path = directory / "out.neo.run" + if not path.is_file(): + return () + return tuple( + line.strip() + for line in path.read_text(encoding="utf-8", errors="replace").splitlines() + if "ERROR" in line + ) + + +def collect_neo_outputs(workdir: str | Path) -> Optional[NeoOutputs]: + """Read a NEO run directory without re-running it. + + Returns ``None`` when the directory holds no NEO output at all, and a + partially populated container when a run wrote only some of its products. + Every field that has no file stays ``None``; see :meth:`NeoOutputs.missing`. + """ + directory = Path(workdir) + if not directory.is_dir(): + return None + produced = sorted(path.name for path in directory.glob("out.neo.*")) + if not produced: + return None + + grid = _parse_grid(directory) + species = _load(directory / "out.neo.species") + species_mass = species_charge = None + if species is not None: + flat = np.atleast_1d(species).ravel() + species_mass, species_charge = flat[0::2], flat[1::2] + + n_species = 0 + if grid is not None: + n_species = grid.n_species + elif species_charge is not None: + n_species = int(species_charge.size) + + normalisation = None + expnorm = _load(directory / "out.neo.expnorm") + if expnorm is not None: + table = _rows(expnorm) + normalisation = NeoNormalisation( + r_over_a=table[:, 0], + a_meters=table[:, 1], + mass_deuterium=table[:, 2], + density_norm=table[:, 3], + temperature_norm=table[:, 4], + velocity_norm_times_a=table[:, 5], + b_unit=table[:, 6], + ) + + coordinates = None + exprhon = _load(directory / "out.neo.exprhon") + if exprhon is not None: + table = _rows(exprhon) + coordinates = { + "r_over_a": table[:, 0], + "rho_tor_norm": table[:, 1], + "psi_norm": table[:, 2], + } + + rotation = None + rotation_table = _load(directory / "out.neo.rotation") + if rotation_table is not None: + table = _rows(rotation_table) + rotation = {"r_over_a": table[:, 0], "raw": table} + + precision = _load(directory / "out.neo.prec") + return NeoOutputs( + directory=str(directory), + grid=grid, + species_mass=species_mass, + species_charge=species_charge, + normalisation=normalisation, + coordinates=coordinates, + equilibrium=_parse_equilibrium(directory, n_species) if n_species else None, + transport=_parse_transport(directory / "out.neo.transport", n_species), + transport_gyroviscous=_parse_transport_gv( + directory / "out.neo.transport_gv", n_species + ), + transport_experimental=_parse_transport( + directory / "out.neo.transport_exp", n_species + ), + transport_gyrobohm=_parse_transport_flux( + directory / "out.neo.transport_flux", n_species + ), + theory=_parse_theory(directory, n_species) if n_species else None, + rotation=rotation, + geometry=_parse_geometry(directory), + precision=None if precision is None else float(np.atleast_1d(precision).ravel()[0]), + version=_parse_version(directory), + errors=_parse_errors(directory), + files=tuple(produced), + ) diff --git a/vaft/code/gacode/neo/runner.py b/vaft/code/gacode/neo/runner.py new file mode 100644 index 00000000..2894567f --- /dev/null +++ b/vaft/code/gacode/neo/runner.py @@ -0,0 +1,147 @@ +"""Execute NEO and collect its native result. + +VAFT drives ``/neo/bin/neo``, the launcher, rather than the Fortran +binary underneath it. The launcher expands ``input.neo`` into the +``input.neo.gen`` the binary actually reads, and stamps ``out.neo.version`` with +the revision, platform and date -- which is where the run's executable identity +comes from. Calling the binary directly would skip both. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from .._runtime import gacode_platform, require_gacode_executable +from .._runtime import run_gacode +from .._profiles import GACODEProfile +from ._types import NEOConfig, NEOResult +from .inputs import NEOInputs, prepare_neo_case +from .outputs import NeoOutputs, collect_neo_outputs + + +class NEOExecutionError(RuntimeError): + """NEO ran and did not produce a usable result. + + Separate from the runtime's ``FileNotFoundError`` and + ``ExecutableNotLaunchable``, which mean it never started. + """ + + +def run_neo( + inputs: NEOInputs, + config: Optional[NEOConfig] = None, + *, + check: bool = True, +) -> NEOResult: + """Run NEO on an already-staged case and parse everything it wrote. + + Parameters + ---------- + inputs + A staged case from :func:`prepare_neo_case`. + config + Runtime settings. Only the GACODE-side fields matter here; the + numerical ones were baked into ``input.neo`` at staging. + check + Raise :class:`NEOExecutionError` on a failed run. With ``check=False`` + the failure is returned instead, which is what a scan wants: the log and + whatever NEO managed to write are still on the result. + + Raises + ------ + FileNotFoundError + GACODE is not configured, or the launcher is missing. + NEOExecutionError + NEO exited non-zero, or exited zero having written nothing usable. + """ + configuration = config or NEOConfig() + executable = require_gacode_executable(configuration, "neo") + # Resolved before launching: a wrong platform otherwise fails inside a shell + # script without naming itself. + platform = gacode_platform(configuration) + + workdir = Path(inputs.workdir) + # Whatever an earlier run left here is not this run's result. Parsing is by + # file name, so a rerun that fails early would otherwise return the previous + # run's physics as its own; only NEO's products are removed. + for stale in workdir.glob("out.neo.*"): + if stale.is_file(): + stale.unlink() + + # The launcher joins its -e argument onto $PWD, so it is run from the parent + # with the case named relatively. + returncode, log = run_gacode( + executable, + ["-e", workdir.name, "-n", str(int(configuration.n_mpi)), + "-nomp", str(int(configuration.n_omp))], + cwd=workdir.parent, + log_path=workdir / "neo.log", + config=configuration, + code="neo", + ) + native = collect_neo_outputs(workdir) + result = NEOResult( + returncode=returncode, + workdir=workdir, + logs=(log,), + outputs={"native": tuple(sorted(workdir.glob("out.neo.*")))}, + outputs_native=native, + provenance={ + "executable": str(executable), + "platform": platform, + "parameters": dict(inputs.parameters), + "inputs": dict(inputs.provenance), + "version": None if native is None else native.version, + }, + ) + if check and not result.ok: + raise NEOExecutionError(_failure_message(result, log)) + return result + + +def _failure_message(result: NEOResult, log: Path) -> str: + tail = "" + try: + lines = log.read_text(encoding="utf-8", errors="replace").splitlines() + tail = "\n".join(lines[-12:]) + except OSError: + pass + native = result.outputs_native + if result.returncode != 0: + reason = f"NEO exited with status {result.returncode}" + elif native is not None and native.errors: + reason = "NEO rejected the case: " + "; ".join(native.errors) + elif native is not None and native.transport is not None: + reason = ( + "NEO completed but its drift-kinetic current is not finite, which is what " + "a degenerate geometry produces" + ) + else: + reason = ( + "NEO exited cleanly but wrote no readable output, which is what a " + "failed parse or an aborted solve looks like" + ) + return f"{reason}. Working directory: {result.workdir}\n{tail}" + + +def run_neo_case( + profile: GACODEProfile, + workdir: str | Path, + config: Optional[NEOConfig] = None, + *, + check: bool = True, +) -> NEOResult: + """Stage and run one NEO case: the one-call path. + + Equivalent to :func:`prepare_neo_case` followed by :func:`run_neo`, and the + counterpart of ``vaft.code.chease.refine_equilibrium`` and + ``vaft.code.nubeam.run_nubeam_case``. + """ + staged = prepare_neo_case(profile, workdir, config) + return run_neo(staged, config, check=check) + + +def read_neo_case(workdir: str | Path) -> Optional[NeoOutputs]: + """Read a finished run directory without re-running it.""" + return collect_neo_outputs(workdir) diff --git a/vaft/data/cocos.py b/vaft/data/cocos.py index 989d9d63..6239e74a 100644 --- a/vaft/data/cocos.py +++ b/vaft/data/cocos.py @@ -275,6 +275,26 @@ def known_codes() -> tuple[str, ...]: ), )) +register_convention(CodeConvention( + name="gacode", + cocos=2, + psi_unit="Wb/rad", + reference=( + "GACODE f2py/expro/expro_locsim.f90 (btccw = -sign(torfluxa), " + "ipccw = -sign(q)*sign(torfluxa)); neo/tools/input/reg18/input.gacode" + ), + confirmed=False, + notes=( + "input.gacode (NEO, TGLF, CGYRO). Inferred, not documented upstream: expro " + "reads the field directions from the signs of torfluxa and q with a toroidal " + "angle that runs clockwise from above, and the shipped reg18 file -- a DIII-D " + "discharge in the normal orientation, Bt clockwise and Ip counter-clockwise -- " + "carries torfluxa > 0, bcentr > 0, current < 0, q < 0 and a polflux that falls " + "outward. cocos_transform(11, 2) reproduces every one of those signs from the " + "IMAS description of that orientation, and no other index does." + ), +)) + register_convention(CodeConvention( name="vfit", cocos=1, diff --git a/vaft/formula/__init__.py b/vaft/formula/__init__.py index 5620bd43..5879435a 100644 --- a/vaft/formula/__init__.py +++ b/vaft/formula/__init__.py @@ -30,6 +30,7 @@ "atomic": ".atomic", "statistics": ".statistics", "magnetics": ".magnetics", + "neoclassical": ".neoclassical", } #: The order these submodules were star-imported in when this package loaded @@ -48,6 +49,7 @@ "atomic", "statistics", "magnetics", + "neoclassical", ) #: Names served by ``.catalog`` on first access. Deliberately not in diff --git a/vaft/formula/neoclassical.py b/vaft/formula/neoclassical.py new file mode 100644 index 00000000..caa7f3d0 --- /dev/null +++ b/vaft/formula/neoclassical.py @@ -0,0 +1,1264 @@ +r"""Analytic neoclassical transport formulas: Sauter and Redl. + +Bootstrap current and parallel conductivity from fitted analytic models, as an +independent reference for the drift-kinetic solvers driven through +:mod:`vaft.code.gacode`. Two formulations are provided as distinct functions +rather than as one backend with a switch, because they are different physics +models and their disagreement is a result rather than an error: the 1999 Sauter +fit was built for conventional aspect ratio, while the 2021 Redl refit extends +to the trapped fractions a spherical tokamak actually reaches. + +Everything here is array/scalar numerics on physical quantities. Nothing reads +an ODS, and nothing knows a flux-surface geometry: the trapped fraction, the +flux-surface-averaged pressure gradients and $I(\psi)$ are inputs, supplied by +the schema-facing layer. + +The parallel current a caller usually wants is the sum of two terms, + +$$\langle j_\parallel B\rangle = \sigma_{\mathrm{neo}}\langle E_\parallel B\rangle + + \langle j_\parallel B\rangle_{\mathrm{bs}}$$ + +whose pieces are :func:`sauter_neoclassical_conductivity` (or its Redl +counterpart) and :func:`sauter_bootstrap_current` (or its Redl counterpart). +They are kept apart because an inductive electric field is a state a caller may +or may not have. + +Notation +-------- +n_e : electron density [m^-3] +n_i : ion density [m^-3] +T_e : electron temperature [eV] +T_i : ion temperature [eV] +f_t : fraction of trapped particles on the surface [-] +nu_e_star : Sauter electron collisionality, Eq. (18b) [-] +nu_i_star : Sauter ion collisionality, Eq. (18c) [-] +Z_eff : effective ion charge [-] +epsilon : inverse aspect ratio r/R_0 [-] +q : safety factor [-] +psi : poloidal flux per radian [Wb/rad] +I_psi : the flux function R B_phi [T m] +L31, L32, L34 : Sauter transport coefficients [-] +alpha : Sauter ion-temperature-gradient coefficient [-] + +Conventions +----------- +Temperatures are in electronvolts throughout, matching +:func:`vaft.formula.equilibrium.coulomb_logarithm_from_n_T` and +:func:`vaft.formula.equilibrium.spitzer_resistivity_from_T_e_Z_eff_ln_Lambda`, +not in joules or keV. + +Poloidal flux is **per radian**. An ODS stores ``equilibrium`` psi in full +weber per the IMAS data dictionary, so a caller reading psi from an ODS must +divide by $2\pi$ first; see +:func:`vaft.data.eqdsk.ods_psi_to_wb_per_radian_factor`. + +References +---------- +.. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) 2834. +.. [2] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 9 (2002) 5140 + (erratum). +.. [3] A. Redl, C. Angioni, E. Belli and O. Sauter, Phys. Plasmas 28 (2021) + 022502. +""" + +from __future__ import annotations + +from typing import NamedTuple, Union + +import numpy as np + +from .constants import COLLISIONALITY_COEF + +__all__ = [ + "BootstrapCoefficients", + "coulomb_logarithm_electron_sauter", + "coulomb_logarithm_ion_sauter", + "electron_collisionality_sauter", + "ion_collisionality_sauter", + "redl_bootstrap_coefficients", + "redl_bootstrap_current", + "redl_neoclassical_conductivity", + "sauter_bootstrap_coefficients", + "sauter_bootstrap_current", + "sauter_neoclassical_conductivity", + "sauter_spitzer_conductivity", + "trapped_particle_fraction", +] + +Numeric = Union[float, np.ndarray] + +#: Sauter Eq. (18c) ion-collisionality prefactor, the counterpart of +#: ``COLLISIONALITY_COEF`` for the electron expression. +_ION_COLLISIONALITY_COEF = 4.90e-18 + +#: Sauter Eq. (12) Spitzer-conductivity prefactor, for T_e in eV [S m^-1 eV^-3/2]. +_SPITZER_CONDUCTIVITY_COEF = 1.9012e4 + + +class BootstrapCoefficients(NamedTuple): + """The four dimensionless coefficients of the Sauter bootstrap expression. + + Both :func:`sauter_bootstrap_coefficients` and + :func:`redl_bootstrap_coefficients` return this shape, because the two + models differ only in the fits, not in how the coefficients enter the + current. Each field is an array when any input was an array. + """ + + L31: Numeric + L32: Numeric + L34: Numeric + alpha: Numeric + + +def _maybe_scalar(value: Numeric) -> Numeric: + """Return Python float for 0-d arrays, otherwise return NumPy array.""" + array = np.asarray(value, dtype=float) + if array.ndim == 0: + return float(array) + return array + + +def _validate_positive(name: str, value: Numeric) -> np.ndarray: + """Validate finite positive scalar/array input and return as float array.""" + array = np.asarray(value, dtype=float) + if np.any(~np.isfinite(array)): + raise ValueError(f"{name} must be finite. Got {value!r}") + if np.any(array <= 0.0): + raise ValueError(f"{name} must be > 0. Got {value!r}") + return array + + +def _validate_non_negative(name: str, value: Numeric) -> np.ndarray: + """Validate finite non-negative scalar/array input and return as float array.""" + array = np.asarray(value, dtype=float) + if np.any(~np.isfinite(array)): + raise ValueError(f"{name} must be finite. Got {value!r}") + if np.any(array < 0.0): + raise ValueError(f"{name} must be >= 0. Got {value!r}") + return array + + +def _validate_fraction(name: str, value: Numeric) -> np.ndarray: + """Validate a finite value in [0, 1] and return as float array.""" + array = np.asarray(value, dtype=float) + if np.any(~np.isfinite(array)): + raise ValueError(f"{name} must be finite. Got {value!r}") + if np.any(array < 0.0) or np.any(array > 1.0): + raise ValueError(f"{name} must lie in [0, 1]. Got {value!r}") + return array + + +def _l31_polynomial(x: np.ndarray, Z_eff: np.ndarray) -> np.ndarray: + """Sauter Eq. (14) quartic in X31. + + Shared with Sauter's L34, which is the same polynomial evaluated at X34. + Redl refits both the polynomial and the effective trapped fraction, so it + does not use this. + """ + return ( + (1.0 + 1.4 / (Z_eff + 1.0)) * x + - (1.9 / (Z_eff + 1.0)) * x**2 + + (0.3 / (Z_eff + 1.0)) * x**3 + + (0.2 / (Z_eff + 1.0)) * x**4 + ) + + +def trapped_particle_fraction(epsilon: Numeric) -> Numeric: + r"""Trapped-particle fraction $f_t$ of a circular surface of inverse aspect ratio. + + $$f_t = 1 - \frac{(1-\epsilon)^2}{\sqrt{1-\epsilon^2}\,(1 + 1.46\sqrt{\epsilon})}$$ + + Parameters + ---------- + epsilon : float or np.ndarray + Inverse aspect ratio $r/R_0$ of the surface, in [0, 1) [-]. + + Returns + ------- + float or np.ndarray + Fraction of particles trapped on the surface [-]. + + Raises + ------ + ValueError + For non-finite input, or input outside [0, 1). + + Convention + ---------- + $f_t$ is the flux-surface quantity that enters every coefficient in this + module. This function is the *circular* approximation to it, written in + terms of the inverse aspect ratio alone. A shaped equilibrium's trapped + fraction is an integral over the field-strength distribution on the surface + and differs from this at the tens-of-percent level at strong elongation, so + a caller holding a real equilibrium should compute $f_t$ from it and pass + that instead of calling this. NEO writes its own value to + ``out.neo.diagnostic_geo`` as ``f_trap``. + + Physical interpretation + ----------------------- + The share of the local Maxwellian whose parallel energy is too small to + cross the magnetic well on the outboard side. It rises steeply with + $\epsilon$, which is why bootstrap current matters far more in a spherical + tokamak than in a conventional one. + + Validity + -------- + Empirical fit. Concentric circular surfaces; the coefficient 1.46 is a fit + to the exact integral rather than a derived value. + + Limitations + ----------- + At $\epsilon \to 1$ the expression tends to 1 but the underlying expansion + has long since stopped being controlled. VEST reaches + $\epsilon \approx 0.6$, where this returns about 0.91: nearly every + particle counted as trapped, which is precisely where treating a fitted + neoclassical coefficient as reliable stops being safe. + + References + ---------- + .. [1] Y. R. Lin-Liu and R. L. Miller, Phys. Plasmas 2 (1995) 1666. + .. [2] J. Wesson, *Tokamaks*, 4th ed., Oxford University Press (2011), + Sec. 4.9 (trapped particles). + """ + array = np.asarray(epsilon, dtype=float) + if np.any(~np.isfinite(array)): + raise ValueError(f"epsilon must be finite. Got {epsilon!r}") + if np.any(array < 0.0) or np.any(array >= 1.0): + raise ValueError(f"epsilon must lie in [0, 1). Got {epsilon!r}") + numerator = (1.0 - array) ** 2 + denominator = np.sqrt(1.0 - array**2) * (1.0 + 1.46 * np.sqrt(array)) + return _maybe_scalar(1.0 - numerator / denominator) + + +def coulomb_logarithm_electron_sauter(n_e: Numeric, T_e: Numeric) -> Numeric: + r"""Electron Coulomb logarithm $\ln\Lambda_e$ in the Sauter convention. + + $$\ln\Lambda_e = 31.3 - \ln\!\left(\frac{\sqrt{n_e}}{T_e}\right)$$ + + Parameters + ---------- + n_e : float or np.ndarray + Electron density, strictly positive [m^-3]. + T_e : float or np.ndarray + Electron temperature, strictly positive [eV]. + + Returns + ------- + float or np.ndarray + Electron Coulomb logarithm [-]. + + Raises + ------ + ValueError + For non-finite or non-positive input. + + Convention + ---------- + This is Sauter Eq. (18d), and it is **not** the NRL expression already in + this package: :func:`vaft.formula.equilibrium.coulomb_logarithm_from_n_T` + uses 30.9, this uses 31.3. The difference is about 1.3 percent of a typical + value, but the collisionality coefficients here were fitted with this one, + so mixing them biases $\nu_e^*$ consistently. Use this function wherever a + Sauter or Redl coefficient is downstream. + + Validity + -------- + Thermal electrons well above the ionisation stage; the same + $T_e \gtrsim 10$ eV floor as the NRL form. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (18d). + + See Also + -------- + vaft.formula.equilibrium.coulomb_logarithm_from_n_T : the NRL convention. + """ + n_array = _validate_positive("n_e", n_e) + t_array = _validate_positive("T_e", T_e) + return _maybe_scalar(31.3 - np.log(np.sqrt(n_array) / t_array)) + + +def coulomb_logarithm_ion_sauter(n_i: Numeric, T_i: Numeric, Z: Numeric) -> Numeric: + r"""Ion-ion Coulomb logarithm $\ln\Lambda_{ii}$ in the Sauter convention. + + $$\ln\Lambda_{ii} = 30.0 - \ln\!\left(\frac{Z^3\sqrt{n_i}}{T_i^{3/2}}\right)$$ + + Parameters + ---------- + n_i : float or np.ndarray + Ion density, strictly positive [m^-3]. + T_i : float or np.ndarray + Ion temperature, strictly positive [eV]. + Z : float or np.ndarray + Ion charge number, strictly positive [-]. + + Returns + ------- + float or np.ndarray + Ion-ion Coulomb logarithm [-]. + + Raises + ------ + ValueError + For non-finite or non-positive input. + + Convention + ---------- + Sauter Eq. (18e). The charge enters cubed, so an impurity species changes + this substantially more than it changes $\ln\Lambda_e$. + + Validity + -------- + Thermal ions of a single charge state. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (18e). + """ + n_array = _validate_positive("n_i", n_i) + t_array = _validate_positive("T_i", T_i) + z_array = _validate_positive("Z", Z) + return _maybe_scalar(30.0 - np.log(z_array**3 * np.sqrt(n_array) / t_array**1.5)) + + +def electron_collisionality_sauter( + n_e: Numeric, + T_e: Numeric, + q: Numeric, + R: Numeric, + epsilon: Numeric, + Z_eff: Numeric, + ln_Lambda_e: Numeric | None = None, +) -> Numeric: + r"""Normalised electron collisionality $\nu_e^*$ in the Sauter convention. + + $$\nu_e^* = 6.921\times10^{-18}\, + \frac{q R\, n_e\, Z_{\mathrm{eff}} \ln\Lambda_e}{\epsilon^{3/2} T_e^2}$$ + + Parameters + ---------- + n_e : float or np.ndarray + Electron density, strictly positive [m^-3]. + T_e : float or np.ndarray + Electron temperature, strictly positive [eV]. + q : float or np.ndarray + Safety factor; the magnitude is used, so either sign convention is + accepted [-]. + R : float or np.ndarray + Major radius of the surface, strictly positive [m]. + epsilon : float or np.ndarray + Inverse aspect ratio $r/R$, strictly positive [-]. + Z_eff : float or np.ndarray + Effective ion charge, strictly positive [-]. + ln_Lambda_e : float or np.ndarray, optional + Electron Coulomb logarithm; computed from *n_e* and *T_e* with + :func:`coulomb_logarithm_electron_sauter` when omitted [-]. + + Returns + ------- + float or np.ndarray + Electron collisionality, the ratio of the effective collision frequency + to the banana bounce frequency [-]. + + Raises + ------ + ValueError + For non-finite or non-positive input. + + Convention + ---------- + This is Sauter Eq. (18b) and it is one of **four** mutually inconsistent + collisionality definitions now in this package, a situation tracked in + issue #353. The others are + :func:`vaft.formula.equilibrium.nu_star_from_n_T_B_R_epsilon_kappa_I` + (an engineering form with a $5\times10^{-11}$ prefactor), + :func:`vaft.formula.equilibrium.normalized_collisionality_from_nu_ii_T_i_M_i_R_a_q` + (which reduces to Sauter Eq. 18b only when handed Sauter's own $\nu_{ii}$), + and :func:`vaft.formula.stability.collisionality_from_n_T_B_R`. Only this + one may be passed to the Sauter and Redl coefficient functions here; the + numbers are not interchangeable. + + Physical interpretation + ----------------------- + Below one the plasma is in the banana regime and trapped orbits complete; + above one collisions detrap particles first and the bootstrap coefficients + fall away. + + Validity + -------- + Positive $\epsilon$; the $\epsilon^{-3/2}$ factor diverges on axis, where + the trapped fraction vanishes and the expression has no meaning. + + Limitations + ----------- + The prefactor bundles physical constants evaluated for the Sauter unit + choice; it is not dimensionally reusable with temperatures in keV. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (18b). + """ + n_array = _validate_positive("n_e", n_e) + t_array = _validate_positive("T_e", T_e) + r_array = _validate_positive("R", R) + epsilon_array = _validate_positive("epsilon", epsilon) + z_array = _validate_positive("Z_eff", Z_eff) + q_array = np.asarray(q, dtype=float) + if np.any(~np.isfinite(q_array)): + raise ValueError(f"q must be finite. Got {q!r}") + if ln_Lambda_e is None: + log_array = np.asarray( + coulomb_logarithm_electron_sauter(n_array, t_array), dtype=float + ) + else: + log_array = _validate_positive("ln_Lambda_e", ln_Lambda_e) + value = ( + COLLISIONALITY_COEF + * np.abs(q_array) + * r_array + * n_array + * z_array + * log_array + / (epsilon_array**1.5 * t_array**2) + ) + return _maybe_scalar(value) + + +def ion_collisionality_sauter( + n_i: Numeric, + T_i: Numeric, + q: Numeric, + R: Numeric, + epsilon: Numeric, + Z: Numeric, + ln_Lambda_ii: Numeric | None = None, +) -> Numeric: + r"""Normalised ion collisionality $\nu_i^*$ in the Sauter convention. + + $$\nu_i^* = 4.90\times10^{-18}\, + \frac{q R\, n_i\, Z^4 \ln\Lambda_{ii}}{\epsilon^{3/2} T_i^2}$$ + + Parameters + ---------- + n_i : float or np.ndarray + Ion density, strictly positive [m^-3]. + T_i : float or np.ndarray + Ion temperature, strictly positive [eV]. + q : float or np.ndarray + Safety factor; the magnitude is used [-]. + R : float or np.ndarray + Major radius of the surface, strictly positive [m]. + epsilon : float or np.ndarray + Inverse aspect ratio $r/R$, strictly positive [-]. + Z : float or np.ndarray + Ion charge number, strictly positive [-]. + ln_Lambda_ii : float or np.ndarray, optional + Ion-ion Coulomb logarithm; computed from *n_i*, *T_i* and *Z* with + :func:`coulomb_logarithm_ion_sauter` when omitted [-]. + + Returns + ------- + float or np.ndarray + Ion collisionality [-]. + + Raises + ------ + ValueError + For non-finite or non-positive input. + + Convention + ---------- + Sauter Eq. (18c). With more than one ion species the paper's single-species + reading is ambiguous, and implementations differ. NEO resolved it in 2013 + by evaluating this for the main ion and then scaling by the summed ion + density, $\nu_i^* \to \nu_i^* \sum_s n_s / n_{\mathrm{main}}$, rather than + by the $Z_i^2 Z_{\mathrm{eff}} n_e$ reading; a caller reproducing NEO must + apply that scaling to this result. See issue #353 for the wider + collisionality-convention problem. + + Validity + -------- + Positive $\epsilon$, as for the electron expression. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (18c). + .. [2] E. A. Belli, GACODE ``neo/src/neo_theory.f90``, note of 11 July 2013 + (the multi-species reading NEO adopted). + """ + n_array = _validate_positive("n_i", n_i) + t_array = _validate_positive("T_i", T_i) + r_array = _validate_positive("R", R) + epsilon_array = _validate_positive("epsilon", epsilon) + z_array = _validate_positive("Z", Z) + q_array = np.asarray(q, dtype=float) + if np.any(~np.isfinite(q_array)): + raise ValueError(f"q must be finite. Got {q!r}") + if ln_Lambda_ii is None: + log_array = np.asarray( + coulomb_logarithm_ion_sauter(n_array, t_array, z_array), dtype=float + ) + else: + log_array = _validate_positive("ln_Lambda_ii", ln_Lambda_ii) + value = ( + _ION_COLLISIONALITY_COEF + * np.abs(q_array) + * r_array + * n_array + * z_array**4 + * log_array + / (epsilon_array**1.5 * t_array**2) + ) + return _maybe_scalar(value) + + +def sauter_spitzer_conductivity( + T_e: Numeric, Z_eff: Numeric, ln_Lambda_e: Numeric +) -> Numeric: + r"""Spitzer parallel conductivity $\sigma_{\mathrm{Sptz}}$ in the Sauter normalisation. + + $$\sigma_{\mathrm{Sptz}} = 1.9012\times10^{4}\, + \frac{T_e^{3/2}}{Z_{\mathrm{eff}} N_Z(Z_{\mathrm{eff}}) \ln\Lambda_e}, + \qquad N_Z = 0.58 + \frac{0.74}{0.76 + Z_{\mathrm{eff}}}$$ + + Parameters + ---------- + T_e : float or np.ndarray + Electron temperature, strictly positive [eV]. + Z_eff : float or np.ndarray + Effective ion charge, strictly positive [-]. + ln_Lambda_e : float or np.ndarray + Electron Coulomb logarithm, strictly positive; use + :func:`coulomb_logarithm_electron_sauter` [-]. + + Returns + ------- + float or np.ndarray + Classical parallel conductivity, without trapped-particle correction + [S m^-1]. + + Raises + ------ + ValueError + For non-finite or non-positive input. + + Convention + ---------- + This is the reference conductivity the neoclassical correction multiplies, + Sauter Eq. (12), and it is not the same object as + :func:`vaft.formula.equilibrium.spitzer_resistivity_from_T_e_Z_eff_ln_Lambda`: + that one applies the charge dependence linearly, this one through the + fitted $N_Z$, so their reciprocals differ by tens of percent at + $Z_{\mathrm{eff}} > 1$. Pair this one with the neoclassical corrections in + this module. + + Validity + -------- + Empirical fit. $N_Z$ is a fit to the Spitzer-Harm charge dependence, valid + for $1 \le Z_{\mathrm{eff}} \lesssim 5$. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (12). + .. [2] L. Spitzer and R. Harm, Phys. Rev. 89 (1953) 977. + + See Also + -------- + vaft.formula.equilibrium.spitzer_resistivity_from_T_e_Z_eff_ln_Lambda + """ + t_array = _validate_positive("T_e", T_e) + z_array = _validate_positive("Z_eff", Z_eff) + log_array = _validate_positive("ln_Lambda_e", ln_Lambda_e) + charge_factor = 0.58 + 0.74 / (0.76 + z_array) + value = _SPITZER_CONDUCTIVITY_COEF * t_array**1.5 / ( + z_array * charge_factor * log_array + ) + return _maybe_scalar(value) + + +def sauter_neoclassical_conductivity( + sigma_spitzer: Numeric, f_trap: Numeric, nu_e_star: Numeric, Z_eff: Numeric +) -> Numeric: + r"""Neoclassical parallel conductivity $\sigma_{\mathrm{neo}}$, Sauter 1999. + + $$\frac{\sigma_{\mathrm{neo}}}{\sigma_{\mathrm{Sptz}}} + = 1 - \left(1 + \frac{0.36}{Z}\right) X_{33} + + \frac{0.59}{Z} X_{33}^2 - \frac{0.23}{Z} X_{33}^3$$ + + with $X_{33} = f_t / \left[1 + (0.55 - 0.1 f_t)\sqrt{\nu_e^*} + + 0.45(1-f_t)\nu_e^*/Z^{3/2}\right]$. + + Parameters + ---------- + sigma_spitzer : float or np.ndarray + Reference Spitzer conductivity from + :func:`sauter_spitzer_conductivity` [S m^-1]. + f_trap : float or np.ndarray + Trapped-particle fraction of the surface, in [0, 1] [-]. + nu_e_star : float or np.ndarray + Electron collisionality from + :func:`electron_collisionality_sauter`, non-negative [-]. + Z_eff : float or np.ndarray + Effective ion charge, strictly positive [-]. + + Returns + ------- + float or np.ndarray + Neoclassical parallel conductivity [S m^-1]. + + Raises + ------ + ValueError + For non-finite input, *f_trap* outside [0, 1], negative *nu_e_star*, or + non-positive *Z_eff*. + + Convention + ---------- + The collisionality must be the Sauter Eq. (18b) one; see + :func:`electron_collisionality_sauter` and issue #353. The result is the + coefficient of $\langle E_\parallel B\rangle$, so the Ohmic contribution to + the parallel current is this times that field, added to the bootstrap term + from :func:`sauter_bootstrap_current`. + + Physical interpretation + ----------------------- + Trapped electrons cannot carry parallel current, so the conductivity falls + below Spitzer roughly in proportion to $f_t$; collisions restore it, which + is why the correction weakens as $\nu_e^*$ rises. + + Validity + -------- + Empirical fit. Fitted to numerical solutions of the drift-kinetic + equation at conventional aspect ratio. + + Limitations + ----------- + At the trapped fractions a spherical tokamak reaches, roughly + $f_t \gtrsim 0.6$, this fit is outside the range it was built on; + :func:`redl_neoclassical_conductivity` is the refit that covers it. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (13). + .. [2] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 9 (2002) + 5140 (erratum). + """ + sigma_array = _validate_positive("sigma_spitzer", sigma_spitzer) + trapped = _validate_fraction("f_trap", f_trap) + collisionality = _validate_non_negative("nu_e_star", nu_e_star) + z_array = _validate_positive("Z_eff", Z_eff) + x33 = trapped / ( + 1.0 + + (0.55 - 0.1 * trapped) * np.sqrt(collisionality) + + 0.45 * (1.0 - trapped) * collisionality / z_array**1.5 + ) + ratio = ( + 1.0 + - (1.0 + 0.36 / z_array) * x33 + + (0.59 / z_array) * x33**2 + - (0.23 / z_array) * x33**3 + ) + return _maybe_scalar(sigma_array * ratio) + + +def redl_neoclassical_conductivity( + sigma_spitzer: Numeric, f_trap: Numeric, nu_e_star: Numeric, Z_eff: Numeric +) -> Numeric: + r"""Neoclassical parallel conductivity $\sigma_{\mathrm{neo}}$, Redl 2021. + + $$\frac{\sigma_{\mathrm{neo}}}{\sigma_{\mathrm{Sptz}}} + = 1 - \left(1 + \frac{0.21}{Z}\right) X_{33} + + \frac{0.54}{Z} X_{33}^2 - \frac{0.33}{Z} X_{33}^3$$ + + with $X_{33} = f_t / \left[1 + 0.25(1 - 0.7 f_t)\sqrt{\nu_e^*} + (1 + 0.45\sqrt{Z-1}) + 0.61(1 - 0.41 f_t)\nu_e^*/\sqrt{Z}\right]$. + + Parameters + ---------- + sigma_spitzer : float or np.ndarray + Reference Spitzer conductivity from + :func:`sauter_spitzer_conductivity` [S m^-1]. + f_trap : float or np.ndarray + Trapped-particle fraction of the surface, in [0, 1] [-]. + nu_e_star : float or np.ndarray + Electron collisionality from + :func:`electron_collisionality_sauter`, non-negative [-]. + Z_eff : float or np.ndarray + Effective ion charge, at least one [-]. + + Returns + ------- + float or np.ndarray + Neoclassical parallel conductivity [S m^-1]. + + Raises + ------ + ValueError + For non-finite input, *f_trap* outside [0, 1], negative *nu_e_star*, or + *Z_eff* below one. + + Convention + ---------- + The reference conductivity is still Sauter Eq. (12), so + :func:`sauter_spitzer_conductivity` is the right input here despite the + name; Redl refits the correction, not the normalisation. As in the Sauter + case the collisionality must be the Eq. (18b) one (issue #353). + + Validity + -------- + Empirical fit. Fitted to NEO solutions spanning tight aspect ratio, so it + stays usable at the trapped fractions where the 1999 fit does not. + $Z_{\mathrm{eff}} \ge 1$ is required because $\sqrt{Z-1}$ appears. + + References + ---------- + .. [1] A. Redl, C. Angioni, E. Belli and O. Sauter, Phys. Plasmas 28 (2021) + 022502. + """ + sigma_array = _validate_positive("sigma_spitzer", sigma_spitzer) + trapped = _validate_fraction("f_trap", f_trap) + collisionality = _validate_non_negative("nu_e_star", nu_e_star) + z_array = np.asarray(Z_eff, dtype=float) + if np.any(~np.isfinite(z_array)): + raise ValueError(f"Z_eff must be finite. Got {Z_eff!r}") + if np.any(z_array < 1.0): + raise ValueError(f"Z_eff must be >= 1. Got {Z_eff!r}") + x33 = trapped / ( + 1.0 + + 0.25 + * (1.0 - 0.7 * trapped) + * np.sqrt(collisionality) + * (1.0 + 0.45 * np.sqrt(z_array - 1.0)) + + 0.61 * (1.0 - 0.41 * trapped) * collisionality / np.sqrt(z_array) + ) + ratio = ( + 1.0 + - (1.0 + 0.21 / z_array) * x33 + + (0.54 / z_array) * x33**2 + - (0.33 / z_array) * x33**3 + ) + return _maybe_scalar(sigma_array * ratio) + + +def sauter_bootstrap_coefficients( + f_trap: Numeric, nu_e_star: Numeric, nu_i_star: Numeric, Z_eff: Numeric +) -> BootstrapCoefficients: + r"""The Sauter 1999 bootstrap coefficients $L_{31}$, $L_{32}$, $L_{34}$, $\alpha$. + + Each coefficient is a rational fit in an effective trapped fraction that + collisions reduce, for example + $X_{31} = f_t / [1 + (1 - 0.1 f_t)\sqrt{\nu_e^*} + + 0.5(1-f_t)\nu_e^*/Z]$, with $L_{31}$ a quartic in $X_{31}$. + + Parameters + ---------- + f_trap : float or np.ndarray + Trapped-particle fraction of the surface, in [0, 1] [-]. + nu_e_star : float or np.ndarray + Electron collisionality from + :func:`electron_collisionality_sauter`, non-negative [-]. + nu_i_star : float or np.ndarray + Ion collisionality from :func:`ion_collisionality_sauter`, + non-negative [-]. + Z_eff : float or np.ndarray + Effective ion charge, strictly positive [-]. + + Returns + ------- + BootstrapCoefficients + The four coefficients, each dimensionless and each an array when any + input was an array [-]. + + Raises + ------ + ValueError + For non-finite input, *f_trap* outside [0, 1], a negative + collisionality, or non-positive *Z_eff*. + + Convention + ---------- + Incorporating the 2002 erratum. $L_{32}$ is the sum of the two branches + $F_{32,ee}$ and $F_{32,ei}$, each evaluated at its own effective trapped + fraction; they are not separately meaningful and are not returned apart. + Both collisionalities must be the Sauter Eq. (18b) and (18c) ones, and + *nu_i_star* must already carry the multi-species scaling described in + :func:`ion_collisionality_sauter` if the caller is reproducing NEO. See + issue #353. + + Physical interpretation + ----------------------- + $L_{31}$ multiplies the total pressure gradient, $L_{32}$ the electron + temperature gradient, and $L_{34}\alpha$ the ion temperature gradient. + $\alpha$ is negative in the banana regime, so the ion-temperature term + opposes the other two. + + Validity + -------- + Empirical fit. Quoted by the authors as accurate to a few percent for + $0 \le \nu^* \le 100$ and $1 \le Z_{\mathrm{eff}} \le 5$ at conventional + aspect ratio. + + Limitations + ----------- + The fits were built on surfaces with $f_t$ well below what a spherical + tokamak reaches; at VEST's $f_t \approx 0.7$ they are extrapolations. + :func:`redl_bootstrap_coefficients` is the refit that covers the range, and + the difference between the two is the honest uncertainty band. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eqs. (14)-(17). + .. [2] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 9 (2002) + 5140 (erratum). + """ + trapped = _validate_fraction("f_trap", f_trap) + nu_e = _validate_non_negative("nu_e_star", nu_e_star) + nu_i = _validate_non_negative("nu_i_star", nu_i_star) + z_array = _validate_positive("Z_eff", Z_eff) + root_nu_e = np.sqrt(nu_e) + + x31 = trapped / ( + 1.0 + + (1.0 - 0.1 * trapped) * root_nu_e + + 0.5 * (1.0 - trapped) * nu_e / z_array + ) + l31 = _l31_polynomial(x31, z_array) + + x32e = trapped / ( + 1.0 + + 0.26 * (1.0 - trapped) * root_nu_e + + 0.18 * (1.0 - 0.37 * trapped) * nu_e / np.sqrt(z_array) + ) + f32_ee = ( + (0.05 + 0.62 * z_array) / (z_array * (1.0 + 0.44 * z_array)) + * (x32e - x32e**4) + + 1.0 / (1.0 + 0.22 * z_array) + * (x32e**2 - x32e**4 - 1.2 * (x32e**3 - x32e**4)) + + 1.2 / (1.0 + 0.5 * z_array) * x32e**4 + ) + x32ei = trapped / ( + 1.0 + + (1.0 + 0.6 * trapped) * root_nu_e + + 0.85 * (1.0 - 0.37 * trapped) * nu_e * (1.0 + z_array) + ) + f32_ei = ( + -(0.56 + 1.93 * z_array) / (z_array * (1.0 + 0.44 * z_array)) + * (x32ei - x32ei**4) + + 4.95 / (1.0 + 2.48 * z_array) + * (x32ei**2 - x32ei**4 - 0.55 * (x32ei**3 - x32ei**4)) + - 1.2 / (1.0 + 0.5 * z_array) * x32ei**4 + ) + l32 = f32_ee + f32_ei + + x34 = trapped / ( + 1.0 + + (1.0 - 0.1 * trapped) * root_nu_e + + 0.5 * (1.0 - 0.5 * trapped) * nu_e / z_array + ) + l34 = _l31_polynomial(x34, z_array) + + alpha_0 = -1.17 * (1.0 - trapped) / ( + 1.0 - 0.22 * trapped - 0.19 * trapped**2 + ) + root_nu_i = np.sqrt(nu_i) + alpha = ( + (alpha_0 + 0.25 * (1.0 - trapped**2) * root_nu_i) / (1.0 + 0.5 * root_nu_i) + + 0.315 * nu_i**2 * trapped**6 + ) / (1.0 + 0.15 * nu_i**2 * trapped**6) + + return BootstrapCoefficients( + L31=_maybe_scalar(l31), + L32=_maybe_scalar(l32), + L34=_maybe_scalar(l34), + alpha=_maybe_scalar(alpha), + ) + + +def redl_bootstrap_coefficients( + f_trap: Numeric, nu_e_star: Numeric, nu_i_star: Numeric, Z_eff: Numeric +) -> BootstrapCoefficients: + r"""The Redl 2021 bootstrap coefficients $L_{31}$, $L_{32}$, $L_{34}$, $\alpha$. + + The same four-coefficient structure as Sauter 1999, refitted against NEO + over a parameter range that includes tight aspect ratio, for example + $L_{31} = X_{31} + (0.15 X_{31} - 0.22 X_{31}^2 + 0.01 X_{31}^3 + + 0.06 X_{31}^4)/(Z^{1.2} - 0.71)$. + + Parameters + ---------- + f_trap : float or np.ndarray + Trapped-particle fraction of the surface, in [0, 1] [-]. + nu_e_star : float or np.ndarray + Electron collisionality from + :func:`electron_collisionality_sauter`, non-negative [-]. + nu_i_star : float or np.ndarray + Ion collisionality from :func:`ion_collisionality_sauter`, + non-negative [-]. + Z_eff : float or np.ndarray + Effective ion charge, at least one [-]. + + Returns + ------- + BootstrapCoefficients + The four coefficients, each dimensionless [-]. + + Raises + ------ + ValueError + For non-finite input, *f_trap* outside [0, 1], a negative + collisionality, or *Z_eff* below one. + + Convention + ---------- + $L_{34}$ is set equal to $L_{31}$: Redl does not refit it separately, and + the field is kept only so that the return shape matches + :func:`sauter_bootstrap_coefficients` and the shared current assembly can + consume either. The collisionality convention is unchanged from Sauter + (issue #353), and $Z_{\mathrm{eff}} \ge 1$ is required because + $\sqrt{Z-1}$ appears in several denominators. + + Validity + -------- + Empirical fit. Refitted against NEO across aspect ratios reaching the + spherical-tokamak range, which is the reason to prefer it over the 1999 fit + for VEST. + + Limitations + ----------- + Agreement with the 1999 fit is close at conventional aspect ratio and + degrades as $f_t$ rises; treat a large Sauter-Redl gap as a signal that the + analytic model is being asked for more than it can give, not as an error in + either. + + References + ---------- + .. [1] A. Redl, C. Angioni, E. Belli and O. Sauter, Phys. Plasmas 28 (2021) + 022502. + """ + trapped = _validate_fraction("f_trap", f_trap) + nu_e = _validate_non_negative("nu_e_star", nu_e_star) + nu_i = _validate_non_negative("nu_i_star", nu_i_star) + z_array = np.asarray(Z_eff, dtype=float) + if np.any(~np.isfinite(z_array)): + raise ValueError(f"Z_eff must be finite. Got {Z_eff!r}") + if np.any(z_array < 1.0): + raise ValueError(f"Z_eff must be >= 1. Got {Z_eff!r}") + root_nu_e = np.sqrt(nu_e) + z_minus_one = z_array - 1.0 + + x31 = trapped / ( + 1.0 + + 0.67 * (1.0 - 0.7 * trapped) * root_nu_e / (0.56 + 0.44 * z_array) + + (0.52 + 0.086 * root_nu_e) + * (1.0 + 0.87 * trapped) + * nu_e + / (1.0 + 1.13 * np.sqrt(z_minus_one)) + ) + l31 = x31 + ( + 0.15 * x31 - 0.22 * x31**2 + 0.01 * x31**3 + 0.06 * x31**4 + ) / (z_array**1.2 - 0.71) + + x32e = trapped / ( + 1.0 + + 0.23 * (1.0 - 0.96 * trapped) * np.sqrt(nu_e / z_array) + + 0.13 + * (1.0 - 0.38 * trapped) + * nu_e + / z_array**2 + * ( + np.sqrt(1.0 + 2.0 * np.sqrt(z_minus_one)) + + trapped**2 * np.sqrt(nu_e * (0.075 + 0.25 * z_minus_one**2)) + ) + ) + f32_ee = ( + (0.1 + 0.6 * z_array) + / (z_array * (0.77 + 0.63 * (1.0 + z_minus_one**1.1))) + * (x32e - x32e**4) + + 0.7 / (1.0 + 0.2 * z_array) + * (x32e**2 - x32e**4 - 1.2 * (x32e**3 - x32e**4)) + + 1.3 / (1.0 + 0.5 * z_array) * x32e**4 + ) + x32ei = trapped / ( + 1.0 + + 0.87 * (1.0 + 0.39 * trapped) * root_nu_e / (1.0 + 2.95 * z_minus_one**2) + + 1.53 * (1.0 - 0.37 * trapped) * nu_e * (2.0 + 0.375 * z_minus_one) + ) + f32_ei = ( + -(0.4 + 1.93 * z_array) / (z_array * (0.8 + 0.6 * z_array)) + * (x32ei - x32ei**4) + + 5.5 / (1.5 + 2.0 * z_array) + * (x32ei**2 - x32ei**4 - 0.8 * (x32ei**3 - x32ei**4)) + - 1.3 / (1.0 + 0.5 * z_array) * x32ei**4 + ) + l32 = f32_ee + f32_ei + + alpha_0 = ( + -(0.62 + 0.055 * z_minus_one) + / (0.53 + 0.17 * z_minus_one) + * (1.0 - trapped) + / (1.0 - trapped * (0.31 - 0.065 * z_minus_one) - 0.25 * trapped**2) + ) + alpha = ( + (alpha_0 + 0.7 * z_array * np.sqrt(trapped * nu_i)) + / (1.0 + 0.18 * np.sqrt(nu_i)) + - 0.002 * nu_i**2 * trapped**6 + ) / (1.0 + 0.004 * nu_i**2 * trapped**6) + + return BootstrapCoefficients( + L31=_maybe_scalar(l31), + L32=_maybe_scalar(l32), + L34=_maybe_scalar(l31), + alpha=_maybe_scalar(alpha), + ) + + +def _assemble_bootstrap_current( + coefficients: BootstrapCoefficients, + I_psi: Numeric, + p_e: Numeric, + p_i: Numeric, + dp_dpsi: Numeric, + dln_Te_dpsi: Numeric, + dln_Ti_dpsi: Numeric, +) -> Numeric: + """Combine coefficients and gradients into the bootstrap current. + + Shared by the Sauter and Redl entry points because the assembly is common + to both models; only the coefficient fits differ. + """ + i_array = np.asarray(I_psi, dtype=float) + pe_array = np.asarray(p_e, dtype=float) + pi_array = np.asarray(p_i, dtype=float) + dp_array = np.asarray(dp_dpsi, dtype=float) + dte_array = np.asarray(dln_Te_dpsi, dtype=float) + dti_array = np.asarray(dln_Ti_dpsi, dtype=float) + for name, array in ( + ("I_psi", i_array), + ("p_e", pe_array), + ("p_i", pi_array), + ("dp_dpsi", dp_array), + ("dln_Te_dpsi", dte_array), + ("dln_Ti_dpsi", dti_array), + ): + if np.any(~np.isfinite(array)): + raise ValueError(f"{name} must be finite.") + value = -i_array * ( + np.asarray(coefficients.L31, dtype=float) * dp_array + + np.asarray(coefficients.L32, dtype=float) * pe_array * dte_array + + np.asarray(coefficients.L34, dtype=float) + * np.asarray(coefficients.alpha, dtype=float) + * pi_array + * dti_array + ) + return _maybe_scalar(value) + + +def sauter_bootstrap_current( + f_trap: Numeric, + nu_e_star: Numeric, + nu_i_star: Numeric, + Z_eff: Numeric, + I_psi: Numeric, + p_e: Numeric, + p_i: Numeric, + dp_dpsi: Numeric, + dln_Te_dpsi: Numeric, + dln_Ti_dpsi: Numeric, +) -> Numeric: + r"""Flux-surface-averaged bootstrap current $\langle j_\parallel B\rangle$, Sauter 1999. + + $$\langle j_\parallel B\rangle_{\mathrm{bs}} = -I(\psi)\left[ + L_{31}\frac{\partial p}{\partial\psi} + + L_{32}\,p_e \frac{\partial \ln T_e}{\partial\psi} + + L_{34}\alpha\,p_i \frac{\partial \ln T_i}{\partial\psi}\right]$$ + + Parameters + ---------- + f_trap : float or np.ndarray + Trapped-particle fraction of the surface, in [0, 1] [-]. + nu_e_star : float or np.ndarray + Electron collisionality from + :func:`electron_collisionality_sauter` [-]. + nu_i_star : float or np.ndarray + Ion collisionality from :func:`ion_collisionality_sauter` [-]. + Z_eff : float or np.ndarray + Effective ion charge, strictly positive [-]. + I_psi : float or np.ndarray + The flux function $I = R B_\phi$ of the surface [T m]. + p_e : float or np.ndarray + Electron pressure $n_e T_e$ [Pa]. + p_i : float or np.ndarray + Summed thermal-ion pressure $\sum_{\mathrm{ions}} n_s T_s$ [Pa]. + dp_dpsi : float or np.ndarray + Derivative of the total thermal pressure, electrons included, with + respect to poloidal flux per radian [Pa rad Wb^-1]. + dln_Te_dpsi : float or np.ndarray + Logarithmic derivative of the electron temperature with respect to + poloidal flux per radian [rad Wb^-1]. + dln_Ti_dpsi : float or np.ndarray + Logarithmic derivative of the ion temperature with respect to poloidal + flux per radian [rad Wb^-1]. + + Returns + ------- + float or np.ndarray + Flux-surface-averaged bootstrap current density times field strength + [A T m^-2]. + + Raises + ------ + ValueError + For non-finite input, *f_trap* outside [0, 1], a negative + collisionality, or non-positive *Z_eff*. + + Convention + ---------- + Poloidal flux is **per radian**, not the full weber the IMAS data + dictionary stores; convert an ODS-sourced psi with + :func:`vaft.data.eqdsk.ods_psi_to_wb_per_radian_factor` before + differentiating. The sign follows from that choice together with the sign + of $I(\psi)$, so a COCOS mismatch shows up here as a sign flip rather than + as a magnitude error. + + This is the bootstrap term alone. The Ohmic term + $\sigma_{\mathrm{neo}}\langle E_\parallel B\rangle$, with + $\sigma_{\mathrm{neo}}$ from :func:`sauter_neoclassical_conductivity`, is + added by the caller when an inductive field is known. + + The single ion temperature in the last term is the paper's reduction for + ions that share a temperature; with unlike ion temperatures, use the main + ion's logarithmic gradient against the summed ion pressure, which is what + NEO does. + + Physical interpretation + ----------------------- + Trapped particles on adjacent orbits carry unequal momentum where a + gradient exists, and the resulting banana current is transferred to the + passing population by collisions. It is a pressure-gradient-driven current + that needs no loop voltage, which is why it dominates the current budget of + a high-beta spherical tokamak. + + Validity + -------- + Empirical fit. Inherits the range of + :func:`sauter_bootstrap_coefficients`. + + Limitations + ----------- + At VEST's trapped fraction this is an extrapolation of the 1999 fit; + compare against :func:`redl_bootstrap_current` rather than trusting either + alone. It also assumes the local gradients are resolved: on a reconstructed + equilibrium the near-axis region often is not. + + References + ---------- + .. [1] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 6 (1999) + 2834, Eq. (11). + .. [2] O. Sauter, C. Angioni and Y. R. Lin-Liu, Phys. Plasmas 9 (2002) + 5140 (erratum). + + See Also + -------- + vaft.formula.equilibrium.bootstrap_current_fraction : the zero-dimensional + heuristic this supersedes for profile work. + redl_bootstrap_current : the 2021 refit, preferred at tight aspect ratio. + """ + coefficients = sauter_bootstrap_coefficients(f_trap, nu_e_star, nu_i_star, Z_eff) + return _assemble_bootstrap_current( + coefficients, I_psi, p_e, p_i, dp_dpsi, dln_Te_dpsi, dln_Ti_dpsi + ) + + +def redl_bootstrap_current( + f_trap: Numeric, + nu_e_star: Numeric, + nu_i_star: Numeric, + Z_eff: Numeric, + I_psi: Numeric, + p_e: Numeric, + p_i: Numeric, + dp_dpsi: Numeric, + dln_Te_dpsi: Numeric, + dln_Ti_dpsi: Numeric, +) -> Numeric: + r"""Flux-surface-averaged bootstrap current $\langle j_\parallel B\rangle$, Redl 2021. + + The assembly is identical to :func:`sauter_bootstrap_current`; only the + coefficients differ, coming from :func:`redl_bootstrap_coefficients`. + + Parameters + ---------- + f_trap : float or np.ndarray + Trapped-particle fraction of the surface, in [0, 1] [-]. + nu_e_star : float or np.ndarray + Electron collisionality from + :func:`electron_collisionality_sauter` [-]. + nu_i_star : float or np.ndarray + Ion collisionality from :func:`ion_collisionality_sauter` [-]. + Z_eff : float or np.ndarray + Effective ion charge, at least one [-]. + I_psi : float or np.ndarray + The flux function $I = R B_\phi$ of the surface [T m]. + p_e : float or np.ndarray + Electron pressure $n_e T_e$ [Pa]. + p_i : float or np.ndarray + Summed thermal-ion pressure $\sum_{\mathrm{ions}} n_s T_s$ [Pa]. + dp_dpsi : float or np.ndarray + Derivative of the total thermal pressure, electrons included, with + respect to poloidal flux per radian [Pa rad Wb^-1]. + dln_Te_dpsi : float or np.ndarray + Logarithmic derivative of the electron temperature with respect to + poloidal flux per radian [rad Wb^-1]. + dln_Ti_dpsi : float or np.ndarray + Logarithmic derivative of the ion temperature with respect to poloidal + flux per radian [rad Wb^-1]. + + Returns + ------- + float or np.ndarray + Flux-surface-averaged bootstrap current density times field strength + [A T m^-2]. + + Raises + ------ + ValueError + For non-finite input, *f_trap* outside [0, 1], a negative + collisionality, or *Z_eff* below one. + + Convention + ---------- + Identical to :func:`sauter_bootstrap_current`: poloidal flux per radian, + sign carried by $I(\psi)$, bootstrap term only. + + Validity + -------- + Empirical fit. Inherits the range of + :func:`redl_bootstrap_coefficients`, which covers tight aspect ratio and is + therefore the one to prefer for VEST. + + Limitations + ----------- + Being the better-conditioned fit does not make it exact; a drift-kinetic + solve through :mod:`vaft.code.gacode` remains the reference. + + References + ---------- + .. [1] A. Redl, C. Angioni, E. Belli and O. Sauter, Phys. Plasmas 28 (2021) + 022502. + + See Also + -------- + sauter_bootstrap_current : the 1999 formulation. + """ + coefficients = redl_bootstrap_coefficients(f_trap, nu_e_star, nu_i_star, Z_eff) + return _assemble_bootstrap_current( + coefficients, I_psi, p_e, p_i, dp_dpsi, dln_Te_dpsi, dln_Ti_dpsi + )