From a39cf7d74e02c5502926412f4acdc4bd94fa42d4 Mon Sep 17 00:00:00 2001 From: Yun Date: Mon, 7 Sep 2026 18:37:21 +0900 Subject: [PATCH 1/2] Establish GACODE integration with Sauter/Redl and NEO (#550 phases 1-4) Adds the kinetic/transport external-code boundary VAFT was missing, plus the VAFT-native analytic neoclassical reference it is checked against. Sauter and NEO stay distinct computational identities: there is no `model=` dispatcher, because a fitted formula and a drift-kinetic solve answer related but different questions, and hiding that behind one API would conceal the difference this branch exists to measure. vaft.formula.neoclassical Twelve pure array/scalar kernels: the trapped fraction, the Sauter Coulomb logarithms and collisionalities, the Spitzer reference conductivity, and both the Sauter 1999 (with the 2002 erratum) and Redl 2021 bootstrap and conductivity formulations. Both are provided because at VEST's trapped fraction the 1999 fit is an extrapolation and the 2021 refit is not. vaft.code.gacode Suite-level runtime, shaped like vaft.code.gpec and disciplined like vaft.code.nubeam: $GACODEHOME with $GACODE_ROOT as a compatibility fallback, explicit platform resolution, and a typed GACODEProfile with a pure-Python input.gacode reader/writer. GACODE's own env contract is set for the subprocess, never redefined. vaft.code.gacode.neo Input generation, execution through the launcher (which is what stamps out.neo.version), parsers for every out.neo.* product, and a complete NeoOutputs container with a versioned JSON round trip. Nothing is written to an IDS: that audit is phase 5 and is deliberately still open. Verified against a real GACODE build (gafusion/gacode 6357db30): * NEO's shipped reg18 case reproduces out.neo.prec exactly. * A VAFT-written input.gacode produces bit-identical NEO output to the file GACODE itself wrote, and the round trip changes only three unit labels that expro renamed between releases. * VAFT's Sauter and Redl reproduce NEO's own compute_Sauter and compute_Sauter_mod to 1e-8 in two regimes -- reg18 at f_trap 0.56 with a carbon impurity, and VEST 48224 at f_trap 0.73. * The packaged 48224 kinetic state runs end to end, ODS to NEO. Both cross-checks run offline against committed fixtures from real runs. Three refusals the converter makes rather than guessing: a rho_tor_norm that is really the sqrt(psi_N) proxy is re-derived or refused (#276, #420); equilibrium and core_profiles slices are not paired outside a derived tolerance; and a non-positive density or temperature stops the conversion naming the grid point, since GACODE takes logarithmic gradients. The packaged 48224 profiles reach exactly zero at the boundary, so that last one fires -- rho_max= makes the truncation the caller's explicit, recorded decision. Note for anyone reading NEO output elsewhere: pygacode's own read_theory is stale against neo_theory.f90. It reads the per-species block three-wide, where the writer emits two values per species and then two trailing scalars -- 24 columns against the 23 reg18 actually has. The layout here is the writer's, and the tests pin it at two and three species. Co-Authored-By: Claude Opus 5 --- .gitattributes | 4 + README.md | 9 + docs/_guide/Formula_reference_neoclassical.md | 92 + external/gacode/README.md | 70 + external/gacode/macos.sh | 148 ++ install/README.md | 23 + install/check_gacode.py | 309 +++ .../initialize_external_fusion_codes.ipynb | 25 +- test/data/gacode/neo_reg18/input.gacode | 2063 +++++++++++++++++ test/data/gacode/neo_reg18/input.neo | 13 + .../gacode/neo_reg18/out.neo.diagnostic_geo | 117 + test/data/gacode/neo_reg18/out.neo.equil | 1 + test/data/gacode/neo_reg18/out.neo.expnorm | 1 + test/data/gacode/neo_reg18/out.neo.exprhon | 1 + test/data/gacode/neo_reg18/out.neo.grid | 23 + test/data/gacode/neo_reg18/out.neo.prec | 1 + test/data/gacode/neo_reg18/out.neo.rotation | 1 + test/data/gacode/neo_reg18/out.neo.species | 1 + test/data/gacode/neo_reg18/out.neo.theory | 1 + test/data/gacode/neo_reg18/out.neo.transport | 1 + .../gacode/neo_reg18/out.neo.transport_exp | 1 + .../gacode/neo_reg18/out.neo.transport_flux | 16 + .../gacode/neo_reg18/out.neo.transport_gv | 1 + test/data/gacode/neo_reg18/out.neo.vel | 1 + test/data/gacode/neo_reg18/out.neo.version | 3 + test/data/gacode/neo_vest_48224/input.neo | 10 + .../neo_vest_48224/out.neo.diagnostic_geo | 117 + test/data/gacode/neo_vest_48224/out.neo.equil | 1 + .../gacode/neo_vest_48224/out.neo.expnorm | 1 + .../gacode/neo_vest_48224/out.neo.exprhon | 1 + test/data/gacode/neo_vest_48224/out.neo.grid | 23 + test/data/gacode/neo_vest_48224/out.neo.prec | 1 + .../gacode/neo_vest_48224/out.neo.species | 1 + .../data/gacode/neo_vest_48224/out.neo.theory | 1 + .../gacode/neo_vest_48224/out.neo.transport | 1 + .../gacode/neo_vest_48224/out.neo.version | 3 + test/test_external_code_environment.py | 18 +- test/test_formula_catalog.py | 1 + test/test_formula_docstrings.py | 26 + test/test_formula_lazy_namespace.py | 1 + test/test_formula_neoclassical.py | 679 ++++++ test/test_gacode_adapter.py | 493 ++++ test/test_gacode_input.py | 435 ++++ vaft/code/__init__.py | 12 + vaft/code/gacode/__init__.py | 88 + vaft/code/gacode/_input_gacode.py | 375 +++ vaft/code/gacode/_profiles.py | 175 ++ vaft/code/gacode/_runtime.py | 241 ++ vaft/code/gacode/_types.py | 76 + vaft/code/gacode/inputs.py | 595 +++++ vaft/code/gacode/neo/__init__.py | 52 + vaft/code/gacode/neo/_types.py | 117 + vaft/code/gacode/neo/inputs.py | 132 ++ vaft/code/gacode/neo/outputs.py | 510 ++++ vaft/code/gacode/neo/runner.py | 132 ++ vaft/formula/__init__.py | 2 + vaft/formula/neoclassical.py | 1264 ++++++++++ 57 files changed, 8503 insertions(+), 7 deletions(-) create mode 100644 docs/_guide/Formula_reference_neoclassical.md create mode 100644 external/gacode/README.md create mode 100644 external/gacode/macos.sh create mode 100644 install/check_gacode.py create mode 100644 test/data/gacode/neo_reg18/input.gacode create mode 100644 test/data/gacode/neo_reg18/input.neo create mode 100644 test/data/gacode/neo_reg18/out.neo.diagnostic_geo create mode 100644 test/data/gacode/neo_reg18/out.neo.equil create mode 100644 test/data/gacode/neo_reg18/out.neo.expnorm create mode 100644 test/data/gacode/neo_reg18/out.neo.exprhon create mode 100644 test/data/gacode/neo_reg18/out.neo.grid create mode 100644 test/data/gacode/neo_reg18/out.neo.prec create mode 100644 test/data/gacode/neo_reg18/out.neo.rotation create mode 100644 test/data/gacode/neo_reg18/out.neo.species create mode 100644 test/data/gacode/neo_reg18/out.neo.theory create mode 100644 test/data/gacode/neo_reg18/out.neo.transport create mode 100644 test/data/gacode/neo_reg18/out.neo.transport_exp create mode 100644 test/data/gacode/neo_reg18/out.neo.transport_flux create mode 100644 test/data/gacode/neo_reg18/out.neo.transport_gv create mode 100644 test/data/gacode/neo_reg18/out.neo.vel create mode 100644 test/data/gacode/neo_reg18/out.neo.version create mode 100644 test/data/gacode/neo_vest_48224/input.neo create mode 100644 test/data/gacode/neo_vest_48224/out.neo.diagnostic_geo create mode 100644 test/data/gacode/neo_vest_48224/out.neo.equil create mode 100644 test/data/gacode/neo_vest_48224/out.neo.expnorm create mode 100644 test/data/gacode/neo_vest_48224/out.neo.exprhon create mode 100644 test/data/gacode/neo_vest_48224/out.neo.grid create mode 100644 test/data/gacode/neo_vest_48224/out.neo.prec create mode 100644 test/data/gacode/neo_vest_48224/out.neo.species create mode 100644 test/data/gacode/neo_vest_48224/out.neo.theory create mode 100644 test/data/gacode/neo_vest_48224/out.neo.transport create mode 100644 test/data/gacode/neo_vest_48224/out.neo.version create mode 100644 test/test_formula_neoclassical.py create mode 100644 test/test_gacode_adapter.py create mode 100644 test/test_gacode_input.py create mode 100644 vaft/code/gacode/__init__.py create mode 100644 vaft/code/gacode/_input_gacode.py create mode 100644 vaft/code/gacode/_profiles.py create mode 100644 vaft/code/gacode/_runtime.py create mode 100644 vaft/code/gacode/_types.py create mode 100644 vaft/code/gacode/inputs.py create mode 100644 vaft/code/gacode/neo/__init__.py create mode 100644 vaft/code/gacode/neo/_types.py create mode 100644 vaft/code/gacode/neo/inputs.py create mode 100644 vaft/code/gacode/neo/outputs.py create mode 100644 vaft/code/gacode/neo/runner.py create mode 100644 vaft/formula/neoclassical.py 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.summary | markdownify | remove: "

    " | remove: "

    " }}
  • +{% endfor %}
+ +{% 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..8354180a --- /dev/null +++ b/test/data/gacode/neo_vest_48224/input.neo @@ -0,0 +1,10 @@ +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 +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..816967a2 --- /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..2204c067 --- /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..24dfed1c --- /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..45c0cd40 --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.prec @@ -0,0 +1 @@ + 0.65527992E+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..4e6057cf --- /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..41ebac0d --- /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..1f86d8cb --- /dev/null +++ b/test/data/gacode/neo_vest_48224/out.neo.version @@ -0,0 +1,3 @@ +6357db30 [2026-07-22] +GFORTRAN_OSX_BREW +Mon Sep 7 17:24:05 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..662d0d19 --- /dev/null +++ b/test/test_gacode_adapter.py @@ -0,0 +1,493 @@ +"""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) -> 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, + ) + + +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) diff --git a/test/test_gacode_input.py b/test/test_gacode_input.py new file mode 100644 index 00000000..db12562f --- /dev/null +++ b/test/test_gacode_input.py @@ -0,0 +1,435 @@ +"""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 + ) 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..9aa6b2d7 --- /dev/null +++ b/vaft/code/gacode/_profiles.py @@ -0,0 +1,175 @@ +"""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. + """ + required = ("rmin", "polflux", "q", "rmaj", "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..c864cd53 --- /dev/null +++ b/vaft/code/gacode/inputs.py @@ -0,0 +1,595 @@ +"""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. + +**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 ..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: + factor = ods_psi_to_wb_per_radian_factor(ods) + derived = rho_tor_profile(q, psi / factor) + 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 + + psi_factor = ods_psi_to_wb_per_radian_factor(ods) + psi = equilibrium_profile("psi") + polflux = None + if psi is not None: + polflux = (psi - psi[0]) * psi_factor + provenance["polflux"] = { + "kind": "derived", + "source": "equilibrium psi, referenced to the axis", + "wb_per_radian_factor": float(psi_factor), + } + + # 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. Taking phi[-1] after truncation would + # rescale the whole radial coordinate silently. + phi_full = _array(ods, f"{equilibrium_prefix}.phi") + torfluxa = None + if phi_full is not None: + torfluxa = float(phi_full[-1]) * psi_factor + 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 = 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") + 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=equilibrium_profile("q"), + ptot=equilibrium_profile("pressure"), + fpol=equilibrium_profile("f"), + torfluxa=torfluxa, + rcentr=_scalar(ods, "equilibrium.vacuum_toroidal_field.r0"), + bcentr=_scalar(ods, "equilibrium.vacuum_toroidal_field.b0"), + current=None if current is None else current / 1.0e6, + 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 _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..cdc0b46f --- /dev/null +++ b/vaft/code/gacode/neo/_types.py @@ -0,0 +1,117 @@ +"""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." + ) + 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``: NEO can exit zero having written + nothing usable, so a zero status alone is not success. + """ + + 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 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..6809c98a --- /dev/null +++ b/vaft/code/gacode/neo/outputs.py @@ -0,0 +1,510 @@ +"""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. + +**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 + 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") + + 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"} 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 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), + 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..37c63724 --- /dev/null +++ b/vaft/code/gacode/neo/runner.py @@ -0,0 +1,132 @@ +"""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) + # 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 + if result.returncode != 0: + reason = f"NEO exited with status {result.returncode}" + 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/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 + ) From e20640994f0c567ab4da8a6ccb38d9a14f86b9b5 Mon Sep 17 00:00:00 2001 From: Yun Date: Fri, 11 Sep 2026 15:31:33 +0900 Subject: [PATCH 2/2] Address the cold review of the GACODE/NEO adapter Six findings, five reproduced against the real NEO build and one established from GACODE's own source; all fixed here with a regression test each. A run is not successful because files exist. NEO reports an input error by writing it to out.neo.run and exiting zero, and the launcher creates out.neo.run and out.neo.version before NEO starts, so collect_neo_outputs never returned None after a launch and `ok` was effectively `returncode == 0`. Reproduced: n_theta=16 is rejected by NEO, and run_neo(check=True) returned ok=True with no current. NeoOutputs now parses NEO's errors and exposes `solved` (no error logged, transport written, current finite), and NEOResult.ok requires it. NEOConfig also refuses NEO's own limits up front -- odd n_theta, at most six species -- so the message names the setting. Stale outputs. run_neo parsed whatever out.neo.* the directory held, so a rerun that failed early returned the previous run's physics as its own; reproduced with a good run followed by a rejected one in the same directory. NEO's products are now cleared before launching. kappa was not a NEO requirement. expro reads an absent tag as zero, which is a legitimate delta or zmag but collapses every surface when it is the elongation; NEO then returns NaN without logging anything. Reproduced with reg18 minus kappa: staged, ran, f_trap=nan, ok=True. It is now required, and `solved` catches a non-finite current from any other degenerate geometry. torfluxa used the psi storage factor on phi. profiles_1d.phi is the full flux in weber from every VAFT producer however psi is stored, so for a per-radian ODS torfluxa was written 2*pi too large -- and expro derives B_unit from it. It is now phi/(2*pi) unconditionally; 48224 was correct only because it stores psi in weber. The sign convention. input.gacode was written with COCOS 11 signs, but expro reads the field directions as btccw = -sign(torfluxa) and ipccw = -sign(q)*sign(torfluxa), with the toroidal angle clockwise from above. The shipped reg18 file -- DIII-D in the normal orientation -- carries exactly the signs cocos_transform(11, 2) predicts, so GACODE is registered in vaft.data.cocos as COCOS 2, marked unconfirmed because the index is inferred rather than documented upstream. The converter now applies the transform to torfluxa, bcentr, current, fpol, polflux and vtor, and records it in provenance. On 48224 NEO previously took the field and current as both clockwise; it now takes both counter-clockwise, as IMAS says. Worth stating because it is easy to misread: the normalised NEO writes flips sign with this fix, and so does its b_unit. NEO normalises by a signed B_unit, so the physical current -- the product -- is unchanged and stays parallel to B, as it must with Ip and Bt parallel. The scalar cross-checks could not have caught the mirroring for that reason. The VEST fixture is regenerated under the corrected convention, and VAFT's Sauter and Redl still reproduce NEO's to 1e-8 on it. bcentr came from time index 0. b0 is sampled on the equilibrium time base and VEST's drifts by up to a factor of two within a shot (#325); it is now read at the converted slice. Co-Authored-By: Claude Opus 5 --- test/data/gacode/neo_vest_48224/input.neo | 5 + .../neo_vest_48224/out.neo.diagnostic_geo | 68 ++++++------ test/data/gacode/neo_vest_48224/out.neo.equil | 2 +- .../gacode/neo_vest_48224/out.neo.expnorm | 2 +- test/data/gacode/neo_vest_48224/out.neo.prec | 2 +- .../data/gacode/neo_vest_48224/out.neo.theory | 2 +- .../gacode/neo_vest_48224/out.neo.transport | 2 +- .../gacode/neo_vest_48224/out.neo.version | 2 +- test/test_gacode_adapter.py | 96 +++++++++++++++- test/test_gacode_input.py | 103 ++++++++++++++++++ vaft/code/gacode/_profiles.py | 6 +- vaft/code/gacode/inputs.py | 74 +++++++++++-- vaft/code/gacode/neo/_types.py | 17 ++- vaft/code/gacode/neo/outputs.py | 40 ++++++- vaft/code/gacode/neo/runner.py | 15 +++ vaft/data/cocos.py | 20 ++++ 16 files changed, 400 insertions(+), 56 deletions(-) diff --git a/test/data/gacode/neo_vest_48224/input.neo b/test/data/gacode/neo_vest_48224/input.neo index 8354180a..a1d63595 100644 --- a/test/data/gacode/neo_vest_48224/input.neo +++ b/test/data/gacode/neo_vest_48224/input.neo @@ -3,8 +3,13 @@ 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 index 816967a2..246722bf 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.diagnostic_geo +++ b/test/data/gacode/neo_vest_48224/out.neo.diagnostic_geo @@ -28,23 +28,23 @@ 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 + -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 @@ -62,23 +62,23 @@ 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 + -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 diff --git a/test/data/gacode/neo_vest_48224/out.neo.equil b/test/data/gacode/neo_vest_48224/out.neo.equil index 2204c067..42537b1f 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.equil +++ b/test/data/gacode/neo_vest_48224/out.neo.equil @@ -1 +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 + 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 index 24dfed1c..70573f42 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.expnorm +++ b/test/data/gacode/neo_vest_48224/out.neo.expnorm @@ -1 +1 @@ - 0.50000000E+00 0.27731346E+00 0.33435800E+01 0.57418679E+00 0.88495275E-02 0.20592665E+05 0.30041210E+00 + 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.prec b/test/data/gacode/neo_vest_48224/out.neo.prec index 45c0cd40..403af84d 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.prec +++ b/test/data/gacode/neo_vest_48224/out.neo.prec @@ -1 +1 @@ - 0.65527992E+02 + -0.43318709E+02 diff --git a/test/data/gacode/neo_vest_48224/out.neo.theory b/test/data/gacode/neo_vest_48224/out.neo.theory index 4e6057cf..f0e7a7ea 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.theory +++ b/test/data/gacode/neo_vest_48224/out.neo.theory @@ -1 +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 + 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 index 41ebac0d..9a2b61c7 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.transport +++ b/test/data/gacode/neo_vest_48224/out.neo.transport @@ -1 +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 + 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 index 1f86d8cb..26e3ba42 100644 --- a/test/data/gacode/neo_vest_48224/out.neo.version +++ b/test/data/gacode/neo_vest_48224/out.neo.version @@ -1,3 +1,3 @@ 6357db30 [2026-07-22] GFORTRAN_OSX_BREW -Mon Sep 7 17:24:05 KST 2026 +Fri Sep 11 15:27:24 KST 2026 diff --git a/test/test_gacode_adapter.py b/test/test_gacode_adapter.py index 662d0d19..3d274267 100644 --- a/test/test_gacode_adapter.py +++ b/test/test_gacode_adapter.py @@ -244,7 +244,7 @@ def test_a_run_captures_its_log_and_returns_the_status(tmp_path, installation): # -------------------------------------------------------------------------- -def _profile(n_ion: int = 1) -> GACODEProfile: +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( @@ -264,6 +264,7 @@ def _profile(n_ion: int = 1) -> GACODEProfile: rcentr=0.4, bcentr=0.15, current=0.1, + kappa=np.full(rho.size, 1.5) if kappa else None, ) @@ -491,3 +492,96 @@ def test_a_table_of_the_wrong_width_is_refused_not_strided_over(tmp_path, filena (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 index db12562f..231c17f5 100644 --- a/test/test_gacode_input.py +++ b/test/test_gacode_input.py @@ -433,3 +433,106 @@ def test_rotation_is_left_absent_when_a_species_lacks_it(): 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/gacode/_profiles.py b/vaft/code/gacode/_profiles.py index 9aa6b2d7..55247e6b 100644 --- a/vaft/code/gacode/_profiles.py +++ b/vaft/code/gacode/_profiles.py @@ -170,6 +170,10 @@ def check_neo_requirements(self) -> tuple[str, ...]: A precondition check, not a verdict: it reports what is missing and leaves the decision to the caller, per the boundary in issue #253. """ - required = ("rmin", "polflux", "q", "rmaj", "ne", "te", "ni", "ti", + # 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/inputs.py b/vaft/code/gacode/inputs.py index c864cd53..95c7b4bf 100644 --- a/vaft/code/gacode/inputs.py +++ b/vaft/code/gacode/inputs.py @@ -26,6 +26,16 @@ 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 @@ -40,6 +50,8 @@ 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 @@ -197,8 +209,9 @@ def _resolve_rho(ods: Any, prefix: str) -> tuple[np.ndarray, str]: 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) + 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" @@ -404,25 +417,41 @@ def equilibrium_profile(name: str) -> Optional[np.ndarray]: 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: - polflux = (psi - psi[0]) * psi_factor + # 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. Taking phi[-1] after truncation would - # rescale the whole radial coordinate silently. + # 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]) * psi_factor + 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", @@ -484,7 +513,9 @@ def equilibrium_profile(name: str) -> Optional[np.ndarray]: # 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 = np.vstack([_interpolate(profile_rho, values, rho) 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", @@ -526,6 +557,16 @@ def equilibrium_profile(name: str) -> Optional[np.ndarray]: } 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), @@ -538,13 +579,13 @@ def equilibrium_profile(name: str) -> Optional[np.ndarray]: kappa=equilibrium_profile("elongation"), delta=delta, polflux=polflux, - q=equilibrium_profile("q"), + q=None if q_profile is None else q_profile * sign["Q"], ptot=equilibrium_profile("pressure"), - fpol=equilibrium_profile("f"), + fpol=None if f_profile is None else f_profile * sign["F"], torfluxa=torfluxa, rcentr=_scalar(ods, "equilibrium.vacuum_toroidal_field.r0"), - bcentr=_scalar(ods, "equilibrium.vacuum_toroidal_field.b0"), - current=None if current is None else current / 1.0e6, + 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), @@ -565,6 +606,19 @@ def equilibrium_profile(name: str) -> Optional[np.ndarray]: 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) diff --git a/vaft/code/gacode/neo/_types.py b/vaft/code/gacode/neo/_types.py index cdc0b46f..917a82dc 100644 --- a/vaft/code/gacode/neo/_types.py +++ b/vaft/code/gacode/neo/_types.py @@ -93,6 +93,12 @@ def __post_init__(self) -> None: "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 " @@ -105,8 +111,9 @@ 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``: NEO can exit zero having written - nothing usable, so a zero status alone is not success. + 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 @@ -114,4 +121,8 @@ class NEOResult(CodeResult): @property def ok(self) -> bool: - return self.returncode == 0 and self.outputs_native is not None + return ( + self.returncode == 0 + and self.outputs_native is not None + and self.outputs_native.solved + ) diff --git a/vaft/code/gacode/neo/outputs.py b/vaft/code/gacode/neo/outputs.py index 6809c98a..d1d5be2e 100644 --- a/vaft/code/gacode/neo/outputs.py +++ b/vaft/code/gacode/neo/outputs.py @@ -15,6 +15,12 @@ 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 @@ -170,6 +176,7 @@ class NeoOutputs: geometry: Optional[Mapping[str, float]] = None precision: Optional[float] = None version: Optional[Mapping[str, str]] = None + errors: tuple[str, ...] = () files: tuple[str, ...] = () @property @@ -198,6 +205,20 @@ def trapped_fraction(self) -> Optional[float]: """ 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: @@ -213,7 +234,7 @@ def missing(self) -> tuple[str, ...]: return tuple( f.name for f in fields(self) - if f.name not in {"directory", "files"} and getattr(self, f.name) is None + if f.name not in {"directory", "files", "errors"} and getattr(self, f.name) is None ) # -- serialisation ---------------------------------------------------- @@ -425,6 +446,22 @@ def _parse_version(directory: Path) -> Optional[Mapping[str, str]]: 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. @@ -506,5 +543,6 @@ def collect_neo_outputs(workdir: str | Path) -> Optional[NeoOutputs]: 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 index 37c63724..2894567f 100644 --- a/vaft/code/gacode/neo/runner.py +++ b/vaft/code/gacode/neo/runner.py @@ -62,6 +62,13 @@ def run_neo( 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( @@ -100,8 +107,16 @@ def _failure_message(result: NEOResult, log: Path) -> str: 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 " 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,