Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/_guide/Profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,49 @@ vaft.plot.charge_exchange_time(ods, ion_index=0)
`vaft.plot.plot_TeNe_from_eq(ods, ...)` plots the synthetic profiles produced by the
`core_profiles_from_eq*` helpers.

## Kinetic-profile files

`vaft.data.kinetic_profiles` is the container the kinetic-profile file formats read into and write
from — GPEC's `.kin` today, the Osborne pfile and MARS `PROF*.IN` to follow:

```python
from vaft.data import read_kin, write_kin, normalize_psi

profiles = read_kin("g045453.00750.kin")
profiles.psi_norm[0] # 0.00494621873 — exactly what the file said
profiles.available() # ('n_e', 'n_i', 'T_e', 'T_i', 'omega_exb')
profiles.normalization.method # "as_read"
write_kin(profiles, "again.kin") # byte-identical to this file; see below
```

A file VAFT wrote round-trips byte for byte, and so do the files the MAST-U workflow produces —
59 of the 65 `.kin` files in the reference tree, the named example among them. The other six are
not VAFT's: GPEC's bundled DIII-D example right-aligns its columns, so a negative rotation eats a
separator space, and the MARS-input file uses no leading indent at all. Both read correctly; they
are simply written back in this writer's layout.

The container fixes one unit set — densities in m⁻³, temperatures in eV, angular frequencies in
rad/s, pressures in Pa — because those are what GPEC's own reader consumes. A reader converts into
them; nothing carries free-text units around.

**The radial coordinate is never rescaled on read.** Making it span exactly [0, 1] is
`normalize_psi()`, an explicit operation whose result records that it happened. It has no
default `method=`: passing `axis=`/`edge=` and getting a min–max stretch instead would be the same
silent rescale, only now with a provenance record vouching for it. This is not
fastidiousness: GPEC's `read_kin` re-splines a `.kin` onto a uniform 101-point [0, 1] grid *with
extrapolation* (`nkin = 100` intervals), so it expects a truncated edge and handles it. A real MAST-U file spans
ψ_N = 0.00495 → 1.0, so stretching it moves every interior point — and once the stretched values are
written back, permanently.

**Toroidal rotation and the E×B frequency are separate fields.** `omega_tor` and `omega_exb` are
never merged, and `write_kin` refuses a profile set carrying only the former: the `.kin` rotation
column is ω_E, and a toroidal rotation written there is wrong in a way nothing downstream detects.
`write_kin` also refuses to write a file GPEC would read as something other than what it says:
`omega_exb` holding **any** zero (GPEC substitutes 1e-9 element by element — pass
`allow_zero_rotation=True` for a set converted from an all-zero `PROFROT.IN`, which
`sample/output/converted_from_transp.kin` is), a value that is not finite, or a radial coordinate
that does not increase. Line endings are LF, so a CRLF file does not round-trip byte-identically.

## Exporting

To hand fitted electron profiles to an external code:
Expand Down
78 changes: 78 additions & 0 deletions test/kinetic_profile_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Synthetic kinetic-profile files, shaped like the real ones.

The ``.kin`` layout is GPEC's own (``pentrc/inputs.f90``): six columns
``psi_n, n_i, n_e, T_i, T_e, omega_E``, a header line that is a comment, and
"(nearly) arbitrary header and/or footer, with the exception that no lines
start with a number". The awkward details here are the ones a real file has:
a radial coordinate that does *not* span exactly [0, 1], a negative rotation
column, and a footer.
"""

from __future__ import annotations

import numpy as np

from vaft.data.kinetic_profiles import KIN_HEADER

#: A real MAST-U .kin spans this, not [0, 1] -- which is what makes a
#: min-max rescale on read move every interior point.
TRUNCATED_SPAN = (0.00494621873, 1.0)


def profile_columns(points=41, span=TRUNCATED_SPAN):
"""The six ``.kin`` columns as arrays, in file order."""
psi = np.linspace(span[0], span[1], points)
edge = 0.5 * (1.0 - np.tanh((psi - 0.93) / 0.04))
n_e = 4.0e19 * (0.3 + 0.7 * edge)
return {
"psi_norm": psi,
"n_i": 0.91 * n_e,
"n_e": n_e,
"T_i": 1.2e3 * (0.1 + 0.9 * edge),
"T_e": 1.1e3 * (0.1 + 0.9 * edge),
# Negative, as a real E x B frequency profile is.
"omega_exb": -1.2e4 * (0.2 + 0.8 * psi),
}


def write_kin_file(path, *, points=41, span=TRUNCATED_SPAN, header=True, footer=False,
extra_column=False, columns=None, fortran_exponents=False,
numeric_footer=False, ragged_row=None, name="synthetic.kin"):
"""Write a synthetic ``.kin``; returns the columns it wrote.

``header`` may be a literal string, so a test can pin the header GPEC's
own files carry without importing it from the module under test. The
remaining switches each break one thing: ``fortran_exponents`` writes
``1.0D+00`` as a Fortran tool would, ``numeric_footer`` appends a summary
row *below* a footer (which GPEC stops before and a reader that collects
every numeric line anywhere would swallow), and ``ragged_row`` drops a
token from one row.
"""
data = columns if columns is not None else profile_columns(points, span)
table = np.column_stack(list(data.values()))
if extra_column:
table = np.column_stack([table, np.arange(table.shape[0], dtype=float)])

def render(value):
text = f"{value:.8e}"
return text.replace("e", "D") if fortran_exponents else text

if header is True:
lines = [KIN_HEADER]
elif header:
lines = [header]
else:
lines = []
rows = [" " + " ".join(render(value) for value in row) for row in table]
if ragged_row is not None:
rows[ragged_row] = rows[ragged_row].rsplit(" ", 1)[0]
lines.extend(rows)
if footer or numeric_footer:
lines += ["", "written by a synthetic fixture", "provenance: none"]
if numeric_footer:
# A summary row after the footer: still numeric, but past the end of
# the table as far as GPEC's readtable is concerned.
lines.append(" " + " ".join(render(value) for value in table.mean(axis=0)))
target = path / name
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
return data
29 changes: 29 additions & 0 deletions test/test_data_code_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,35 @@ def test_data_and_code_import_smoke():
assert "data" in dir(vaft)


def test_every_name_vaft_data_advertises_resolves():
"""``vaft.data`` is a lazy namespace: a name in ``__all__`` with no
``_EXPORT_MAP`` entry (or a submodule missing from the ``__getattr__``
set) fails only at attribute access, so nothing but a walk catches it.
``vaft.formula`` has had this check; ``vaft.data`` had not."""
import vaft.data as data

unresolvable = []
for name in data.__all__:
try:
getattr(data, name)
except AttributeError:
unresolvable.append(name)
assert not unresolvable

stale = sorted(set(data._EXPORT_MAP) - set(data.__all__))
assert not stale, f"exported but not advertised: {stale}"


def test_star_importing_vaft_data_binds_the_whole_surface():
import vaft.data as data

namespace: dict[str, object] = {}
exec("from vaft.data import *", namespace) # noqa: S102

for name in data.__all__:
assert name in namespace, name


def test_geqdsk_roundtrip(tmp_path):
from vaft.data import read_geqdsk, write_geqdsk
from vaft.data.resources import data_path
Expand Down
Loading
Loading