Skip to content

Read and write GPEC .kin files without touching the radial coordinate - #651

Merged
HongSik-Yun-Fusion merged 2 commits into
developfrom
claude/kinetic-profiles
Sep 7, 2026
Merged

Read and write GPEC .kin files without touching the radial coordinate#651
HongSik-Yun-Fusion merged 2 commits into
developfrom
claude/kinetic-profiles

Conversation

@HongSik-Yun-Fusion

Copy link
Copy Markdown
Contributor

Second PR of the V4 migration slice (hsyun_GPEC#26): the kinetic-profile container the three file formats share, and GPEC .kin I/O. The Osborne pfile and MARS PROF*.IN follow on top of it.

C-10 — the silent ψ rescale

The legacy readers min–max rescaled the ψ column to [0, 1] on every read, and the writer put the stretched values back. Fixed here by there being no rescale path at all; making the coordinate span [0, 1] is normalize_psi(), an explicit operation whose result records that it happened.

This is a physics error rather than a cosmetic one, and the reason is in GPEC's own source: read_kin (pentrc/inputs.f90) re-splines a .kin onto a uniform 100-point [0, 1] grid with extrapolation. It expects a truncated edge and handles it. A real MAST-U file spans ψ_N = 0.00495 → 1.0, so stretching moves every interior point — and permanently, once written back.

C-27 — the rotation column is ω_E

omega_tor and omega_exb are separate fields, and write_kin refuses a profile set carrying only the former. A toroidal rotation written into the .kin rotation column is wrong in a way nothing downstream can detect, and the formats invite exactly that mistake — MARS splits the two across PROFROT.IN and PROFWE.IN, and a pfile has ten rotation-like sections.

write_kin also refuses an all-zero omega_exb unless asked: GPEC substitutes 1e-9 for every zero to keep its spline from going NaN, so the file would not mean what it says. The committed PROFROT.IN really is all zeros, so this is not hypothetical.

The container

A frozen dataclass with named typed fields, alongside eqdsk.py/aeqdsk.py/keqdsk.py. One unit set fixed by the container — m⁻³, eV, rad/s, Pa, V/m, which is what GPEC consumes, so the .kin path converts nothing and the pfile/MARS readers will convert on the way in through a single table. Not a dict: a dict is what let wexb mean two things. Anything a file carries with no field of its own goes to extras, and provenance records where each profile came from.

The reader implements GPEC's actual contract — "(nearly) arbitrary header and/or footer, with the exception that no lines start with a number" — rather than assuming a fixed header, so a file with a provenance footer round-trips where the legacy col_map parse dropped it.

Verification

18 tests on synthetic fixtures shaped from the real file (a coordinate that does not span [0, 1], a negative rotation column, a footer). Locally, against the real restricted reference: 201 rows, ψ[0] = 0.00494621873, and the round trip is byte-identical to the file it came from — which also confirms the writer's format matches GPEC's own examples.

Develop gate: 4897 passed.

🤖 Generated with Claude Code

The kinetic-profile container the three file formats share, plus the .kin
reader and writer. The Osborne pfile and MARS PROF*.IN follow.

C-10 is fixed by there being no rescale path at all. The legacy readers
min-max rescaled the psi column to [0,1] on every read and the writer put the
stretched values back, so one round trip moved every interior point
permanently. That is a physics error, not a cosmetic one: GPEC's own read_kin
re-splines onto a uniform 100-point [0,1] grid *with extrapolation*, so it
expects a truncated edge and handles it. Making the coordinate span [0,1] is
now normalize_psi(), an explicit operation whose result records that it
happened.

C-27 is fixed in the API rather than in a comment: omega_tor and omega_exb
are separate fields, and write_kin refuses a set carrying only the former,
because the .kin rotation column is omega_E and a toroidal rotation written
there is wrong in a way nothing downstream can detect. An all-zero omega_exb
is refused too unless asked for, since GPEC substitutes 1e-9 for every zero
to keep its spline finite -- the file would not mean what it says.

The reader implements GPEC's actual header/footer contract, "no lines start
with a number", rather than assuming a fixed header, so a file carrying a
provenance footer round-trips. Units are one set fixed by the container,
which is what GPEC consumes, so the .kin path converts nothing.

Verified on the real reference file: 201 rows, psi[0] = 0.00494621873, and
the round trip is byte-identical to the file it came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r that refuses

Review of the .kin reader found it disagreeing with GPEC about where a table
ends, and a suite that asserted the fixture rather than the code.

The reader:

- _data_lines collected every numeric line anywhere in the file. GPEC's
  readtable (pentrc/utilities.f90:428-451) takes the *first contiguous block*:
  it starts at the first line beginning with a number and stops at the next
  line that does not, ignoring the rest. A summary row below a footer was
  therefore a fourth data point here and three there, with no error on either
  side. It now implements the rule GPEC implements.
- The numeric test is GPEC's too -- one character after up to two signs --
  so "nan" and "Infinity" are header text, as they are to GPEC, rather than
  numbers Python happens to accept.
- Fortran D exponents (1.0D+00) were classified as header and silently
  dropped; GPEC's list-directed read accepts them, so a file written by a
  Fortran tool lost rows without a word. Read now.
- A ragged row died inside numpy with an inhomogeneous-shape message naming
  no line. The row is checked and named first.
- Provenance numbered columns from zero, so n_e read "column 2" for the
  file's third column, and extras got no provenance entry at all. Both fixed;
  extras are keyed column_7 rather than column_6 to match.

normalize_psi had method="min_max" as its default, so a caller passing axis=
and edge= got a min-max stretch and a PsiNormalization record certifying it --
the silent rescale this module exists to prevent, now with provenance vouching
for it. method is required, and axis/edge with min_max is refused.

The container was frozen in name only: == raised on its numpy fields, hash
raised, and both the arrays and extras were editable in place behind a
provenance record that said "as read". eq=False, and the arrays are sealed as
read-only *views* so the caller's own array stays writable.

The writer refused an omega_exb that was entirely zero; GPEC substitutes 1e-9
element by element, so a converter that zero-filled the core wrote a file
whose inner half GPEC quietly rewrote. Any zero is refused now, as is a
non-finite value (GPEC's own warning says one NaN ruins the whole spline) and
a coordinate that does not increase (spline_fit assumes it does).

Naming: UNITS is now KINETIC_UNITS and is exported, as are KIN_HEADER and
PROFILE_FIELDS -- the docstring pointed at a vaft.data.kinetic_units module
that does not exist, and none of the three were reachable from vaft.data.
kinetic_profiles is in __all__ alongside its peers.

Tests. The fixture imported KIN_HEADER from the module under test and
re-implemented write_kin's format string, so the byte-level assertions
compared the writer to its own twin: a header with the right tokens and the
wrong spacing passed every test while matching no real file. The header is now
a literal in the test. New cases cover the block rule, the numeric-looking
footer, D exponents, ragged rows, the axis/edge-without-method refusal, the
partly-zero and non-finite and non-monotonic write refusals, sealing (and that
sealing does not reach back into the caller's array), and vaft.data's lazy
namespace, which had no walk of its own though vaft.formula has had one. 18
tests to 29.

Against the reference tree unchanged: 65 .kin files read, 59 written back byte
for byte including the named MAST-U example, 0 errors. The six that differ are
GPEC's own DIII-D example (right-aligned columns) and the MARS-input file (no
indent) -- the docs now say so rather than claiming byte-identity outright.

Corrections: GPEC's grid is 101 points (nkin = 100 intervals), not 100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HongSik-Yun-Fusion
HongSik-Yun-Fusion merged commit 246b33a into develop Sep 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant