Skip to content

Commit 77c581d

Browse files
authored
Run KIN6D's nested equilibrium on fixed-boundary VMEC inputs (#36)
* Run KIN6D's nested equilibrium on fixed-boundary VMEC inputs The converter writes KIN6D's nested_input namelist (boundary oriented as VMEC orients it, iota following the theta flip, power-series profiles) and the result file becomes the usual sidecar. Inputs KIN6D cannot represent yet are reported unsupported. * Link the KIN6D row to its pull request
1 parent 8bb94d1 commit 77c581d

5 files changed

Lines changed: 215 additions & 7 deletions

File tree

‎docs/support.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ enclosed flux and current constraint.
3535
| GVEC | legacy `RAXIS`/`ZAXIS` and scalar axis entries are read as VMEC reads them | [itpplasma/gvec#30](https://github.com/itpplasma/gvec/pull/30) | merged |
3636
| GVEC | after a Jacobian sign change in the initial guess, the run retries with a guessed axis and then with a lower radial power of the shaping modes, as VMEC resets its axis | [itpplasma/gvec#31](https://github.com/itpplasma/gvec/pull/31), [itpplasma/gvec#33](https://github.com/itpplasma/gvec/pull/33) | merged |
3737
| GVEC | files beside native parameter files (wout, hmap, boundary) are staged; only `&INDATA` is parsed | #32, #33 | merged |
38+
| KIN6D | Fixed-boundary VMEC inputs run through KIN6D's `nested_equilibrium` (`tools/convert_vmec_to_kin6d.py`): power-series iota and pressure, boundary oriented as VMEC orients it. Current-constrained (NCURR = 1), non-power-series profiles, BLOAT, SPRES_PED < 1, LRFP and GAMMA != 0 are reported unsupported | #36 | merged |
3839
| DESC | VMEC profile types as splines, VMEC's current sign, free boundary in the mgrid field | [itpplasma/DESC#18](https://github.com/itpplasma/DESC/pull/18), [PlasmaControl/DESC#2392](https://github.com/PlasmaControl/DESC/pull/2392) (profiles, sign); [itpplasma/DESC#17](https://github.com/itpplasma/DESC/pull/17) (free boundary), [PlasmaControl/DESC#2393](https://github.com/PlasmaControl/DESC/pull/2393) (`LFREEB` warning) | fork #18 blocked by CI, #17 draft; upstream ready |
3940
| SPECTRE | VMEC profile types in the VMEC converter; converged wout profiles; iota-constrained conversions start from the poloidal flux and `mu` of the transform, without which axisymmetric cases hit a singular straight-field-line solve | [krystophny/spectre!5](https://gitlab.com/krystophny/spectre/-/merge_requests/5), upstream [spectre-eq/spectre!72](https://gitlab.com/spectre-eq/spectre/-/merge_requests/72) and [!71](https://gitlab.com/spectre-eq/spectre/-/merge_requests/71) | fork merged, upstream draft |
4041
| SPECTRE | VMEC inputs go through SPECTRE's own converter at the deck's resolution, with boundaries oriented as VMEC orients them; volume and aspect ratio come from SPECTRE's output (its innermost surface is a coordinate axis, not the magnetic axis), the force residual from its minimiser; convergence follows the tolerance table below | #24 | open |

‎src/benchmark_runner.f90‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ module benchmark_runner
77
use jvmec_implementation, only: jvmec_t
88
use vmec2000_implementation, only: vmec2000_t
99
use vmecpp_implementation, only: vmecpp_t
10-
use external_vmec_implementation, only: vmex_t, desc_t, gvec_t, parvmec_t, spec_t, spectre_t, freegs_t, chease_t
10+
use external_vmec_implementation, only: vmex_t, desc_t, gvec_t, parvmec_t, spec_t, spectre_t, freegs_t, chease_t, &
11+
kin6d_t
1112
use repository_manager, only: repository_manager_t
1213
use results_comparator, only: results_comparator_t
1314
implicit none
@@ -72,6 +73,7 @@ subroutine benchmark_runner_setup_implementations(this, implementation_filter)
7273
type(vmex_t), allocatable :: vmex
7374
type(desc_t), allocatable :: desc
7475
type(gvec_t), allocatable :: gvec
76+
type(kin6d_t), allocatable :: kin6d
7577
type(parvmec_t), allocatable :: parvmec
7678
type(spec_t), allocatable :: spec
7779
type(spectre_t), allocatable :: spectre
@@ -235,6 +237,23 @@ subroutine benchmark_runner_setup_implementations(this, implementation_filter)
235237
end if
236238
end if
237239

240+
! KIN6D
241+
if (implementation_selected(implementation_filter, 'kin6d') .and. &
242+
this%repo_manager%is_cloned("kin6d")) then
243+
repo_path = this%repo_manager%get_repo_path("kin6d")
244+
allocate(kin6d)
245+
call kin6d%initialize("KIN6D", repo_path)
246+
if (kin6d%build()) then
247+
this%n_implementations = this%n_implementations + 1
248+
this%implementations(this%n_implementations)%key = "kin6d"
249+
call move_alloc(kin6d, this%implementations(this%n_implementations)%impl)
250+
write(output_unit, '(A)') "✓ KIN6D is ready"
251+
else
252+
write(error_unit, '(A)') "✗ KIN6D setup failed"
253+
deallocate(kin6d)
254+
end if
255+
end if
256+
238257
! PARVMEC, SPEC and SPECTRE are retained as native participants. They
239258
! only enter the run when a checkout has a usable executable; their
240259
! native outputs are reported separately from VMEC WOUT quantities.
@@ -711,11 +730,14 @@ subroutine benchmark_runner_run_all_cases(this, comparator, timeout)
711730
call results%clear()
712731
results%error_message = 'Unsupported: FreeGS is restricted to 2-D Grad-Shafranov cases'
713732
write(output_unit, '(A)') ' - freegs skipped (not a 2-D Grad-Shafranov case)'
714-
else if (trim(this%implementations(j)%key) == 'gvec' .and. &
733+
else if ((trim(this%implementations(j)%key) == 'gvec' .or. &
734+
trim(this%implementations(j)%key) == 'kin6d') .and. &
715735
.not. gvec_case_supported(this%test_cases(i)%str)) then
716736
call results%clear()
717-
results%error_message = 'Unsupported: GVEC requires a 2-D or 3-D VMEC input'
718-
write(output_unit, '(A)') ' - gvec skipped (not a 2-D or 3-D VMEC case)'
737+
results%error_message = 'Unsupported: ' // trim(this%implementations(j)%impl%name) // &
738+
' requires a 2-D or 3-D VMEC input'
739+
write(output_unit, '(A)') ' - ' // trim(this%implementations(j)%key) // &
740+
' skipped (not a 2-D or 3-D VMEC case)'
719741
else if (trim(this%implementations(j)%key) == 'chease' .and. &
720742
.not. chease_case_supported(this%test_cases(i)%str)) then
721743
call results%clear()

‎src/external_vmec_implementation.f90‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module external_vmec_implementation
88
implicit none
99
private
1010

11-
public :: vmex_t, desc_t, gvec_t, parvmec_t, spec_t, spectre_t, freegs_t, chease_t
11+
public :: vmex_t, desc_t, gvec_t, parvmec_t, spec_t, spectre_t, freegs_t, chease_t, kin6d_t
1212

1313
! The three projects below have independent command-line interfaces, but
1414
! they all have a Python package and (where a VMEC wout is available) use
@@ -39,6 +39,9 @@ module external_vmec_implementation
3939
type, extends(external_vmec_base_t) :: spectre_t
4040
end type spectre_t
4141

42+
type, extends(external_vmec_base_t) :: kin6d_t
43+
end type kin6d_t
44+
4245
type, extends(external_vmec_base_t) :: freegs_t
4346
end type freegs_t
4447

@@ -101,7 +104,8 @@ function external_vmec_build(this) result(success)
101104
! artifact exists (or the explicit VMEC_BENCHMARK_* override above was
102105
! supplied).
103106
if (trim(lowercase(this%name)) == 'spec' .or. trim(lowercase(this%name)) == 'spectre' .or. &
104-
trim(lowercase(this%name)) == 'parvmec' .or. trim(lowercase(this%name)) == 'chease') then
107+
trim(lowercase(this%name)) == 'parvmec' .or. trim(lowercase(this%name)) == 'chease' .or. &
108+
trim(lowercase(this%name)) == 'kin6d') then
105109
call find_native_executable(this%path, lowercase(this%name), this%executable, exists)
106110
if (exists) then
107111
this%available = .true.
@@ -163,12 +167,14 @@ function external_vmec_run_case(this, input_file, output_dir, timeout) result(su
163167
if (trim(lower_name) == 'vmex' .or. trim(lower_name) == 'parvmec' .or. &
164168
(trim(lower_name) == 'gvec' .and. index(lowercase(basename(local_input)), 'input.') == 1) .or. &
165169
(trim(lower_name) == 'spectre' .and. index(lowercase(basename(local_input)), 'input.') == 1) .or. &
170+
(trim(lower_name) == 'kin6d' .and. index(lowercase(basename(local_input)), 'input.') == 1) .or. &
166171
(trim(lower_name) == 'desc' .and. index(lowercase(basename(local_input)), 'input.') == 1 .and. &
167172
index(lowercase(basename(local_input)), '_desc') == 0)) then
168173
prepared_input = trim(output_dir) // '/input_prepared.vmec'
169174
if (trim(lower_name) == 'desc') then
170175
if (.not. prepare_vmec_input(input_file, prepared_input, this%path, desc_compatible=.true.)) return
171-
else if (trim(lower_name) == 'gvec' .or. trim(lower_name) == 'spectre') then
176+
else if (trim(lower_name) == 'gvec' .or. trim(lower_name) == 'spectre' .or. &
177+
trim(lower_name) == 'kin6d') then
172178
if (.not. prepare_vmec_input(input_file, prepared_input, this%path, &
173179
fixed_boundary_only=trim(this%name), fixed_pressure_only=trim(this%name))) return
174180
else
@@ -290,6 +296,20 @@ function external_vmec_run_case(this, input_file, output_dir, timeout) result(su
290296
end if
291297
case ('parvmec')
292298
cmd = trim(this%executable) // ' ' // basename(local_input)
299+
case ('kin6d')
300+
! KIN6D reads its own nested_input namelist and writes output/<app>.nc
301+
python_cmd = select_python_command(this%path)
302+
cmd = trim(python_cmd) // ' ' // trim(benchmark_root) // '/tools/convert_vmec_to_kin6d.py ' // &
303+
basename(local_input) // ' kin6d_input.nml'
304+
call execute_command_line('cd ' // trim(output_dir) // ' && ' // trim(cmd) // &
305+
' >> kin6d.log 2>&1', exitstat=stat)
306+
if (stat /= 0) then
307+
write(error_unit, '(A)') 'KIN6D VMEC-to-namelist conversion failed'
308+
return
309+
end if
310+
cmd = 'mkdir -p output && ' // trim(this%executable) // ' --config kin6d_input.nml && ' // &
311+
trim(python_cmd) // ' ' // trim(benchmark_root) // '/tools/convert_kin6d_to_common.py ' // &
312+
'output/nested_equilibrium.nc kin6d_result.json'
293313
case ('chease')
294314
if (index(lowercase(basename(local_input)), '.geqdsk') > 0 .or. &
295315
index(lowercase(basename(local_input)), '.eqdsk') > 0) then
@@ -408,6 +428,13 @@ subroutine external_vmec_extract_results(this, output_dir, results)
408428
call extract_spectre_results(output_dir, results)
409429
return
410430
end if
431+
if (trim(lowercase(this%name)) == 'kin6d') then
432+
call read_result_sidecar(trim(output_dir) // '/kin6d_result.json', 'KIN6D', results)
433+
results%family = 'vmec_family'
434+
results%input_format = 'vmec_indata'
435+
results%output_format = 'kin6d_netcdf'
436+
return
437+
end if
411438
if (trim(lowercase(this%name)) == 'chease') then
412439
call extract_chease_results(output_dir, results)
413440
return
@@ -565,6 +592,8 @@ subroutine read_result_sidecar(filename, code, results)
565592
if (json_number(line, 'iotaf_edge', value)) results%iotaf_edge = value
566593
if (json_number(line, 'rmajor_p', value)) results%rmajor_p = value
567594
if (json_number(line, 'aminor_p', value)) results%aminor_p = value
595+
if (json_number(line, 'wb', value)) results%wb = value
596+
if (json_number(line, 'betatotal', value)) results%betatotal = value
568597
end do
569598
close(unit)
570599
results%success = success_value
@@ -768,6 +797,8 @@ subroutine find_native_executable(path, kind, executable, found)
768797
case ('chease')
769798
candidates(1) = trim(path)//'/chease'
770799
candidates(2) = trim(path)//'/bin/chease'
800+
case ('kin6d')
801+
candidates(1) = trim(path)//'/build/nested_equilibrium'
771802
end select
772803
found = .false.
773804
executable = ''
@@ -819,6 +850,10 @@ subroutine external_kind(name, module_name, tool_name, env_name)
819850
module_name = 'chease'
820851
tool_name = 'chease'
821852
env_name = 'chease'
853+
case ('kin6d')
854+
module_name = 'kin6d'
855+
tool_name = 'nested_equilibrium'
856+
env_name = 'kin6d'
822857
case default
823858
module_name = trim(name)
824859
tool_name = trim(name)

‎tools/convert_kin6d_to_common.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env python3
2+
"""Write the benchmark result sidecar from KIN6D's nested_equilibrium result file.
3+
4+
usage: convert_kin6d_to_common.py RESULT.nc SIDECAR.json
5+
"""
6+
from __future__ import annotations
7+
8+
import argparse
9+
import json
10+
from pathlib import Path
11+
12+
_QUANTITIES = (
13+
"volume_p", "aspect", "rmajor_p", "aminor_p", "raxis_cc", "iotaf_edge",
14+
"pressure_axis", "betatotal", "wb", "force_residual",
15+
)
16+
17+
18+
def main() -> int:
19+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
20+
parser.add_argument("result", type=Path)
21+
parser.add_argument("sidecar", type=Path)
22+
args = parser.parse_args()
23+
import netCDF4 # type: ignore
24+
25+
with netCDF4.Dataset(args.result) as data:
26+
summary = {"success": str(data.getncattr("converged")).strip() == "true"}
27+
summary.update({key: float(data[key][()]) for key in _QUANTITIES if key in data.variables})
28+
args.sidecar.write_text(json.dumps(summary, indent=1) + "\n")
29+
return 0
30+
31+
32+
if __name__ == "__main__":
33+
raise SystemExit(main())

‎tools/convert_vmec_to_kin6d.py‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""Write KIN6D's ``nested_input`` namelist for a fixed-boundary VMEC input.
3+
4+
KIN6D's ``nested_equilibrium`` prescribes iota(s) and p(s) as monomials in the
5+
normalized toroidal flux s, with a positive toroidal flux. VMEC flips theta
6+
internally when the boundary runs clockwise in (R, Z) and reverses iota with it;
7+
the same flip is applied here, so iota keeps its handedness relative to the
8+
boundary.
9+
10+
usage: convert_vmec_to_kin6d.py INPUT NAMELIST
11+
"""
12+
from __future__ import annotations
13+
14+
import argparse
15+
from pathlib import Path
16+
17+
import numpy as np
18+
19+
from convert_vmec_to_gvec import _dense_vmec_namelist, _mark_unsupported
20+
21+
# KIN6D's monomial profiles hold at most 33 coefficients
22+
_MAX_COEFFICIENTS = 33
23+
24+
25+
def _monomials(nml: dict, kind: str, prefix: str) -> np.ndarray:
26+
kind_type = str(nml.get(f"{kind}_type", "power_series")).strip().lower()
27+
if kind_type != "power_series":
28+
raise NotImplementedError(f"KIN6D takes power-series profiles, not {kind.upper()}_TYPE = '{kind_type}'")
29+
values = np.atleast_1d(np.asarray(nml.get(prefix, [0.0]), dtype=float))
30+
values = np.trim_zeros(np.nan_to_num(values), "b")
31+
if len(values) > _MAX_COEFFICIENTS:
32+
raise NotImplementedError(f"KIN6D holds at most {_MAX_COEFFICIENTS} {prefix.upper()} coefficients")
33+
return values if len(values) else np.zeros(1)
34+
35+
36+
def _boundary(nml: dict) -> dict[str, dict[tuple[int, int], float]]:
37+
"""Boundary coefficients keyed (n, m) as in VMEC, oriented counterclockwise in (R, Z)."""
38+
ntor = int(nml.get("ntor", 0))
39+
out = {}
40+
for field in ("rbc", "rbs", "zbc", "zbs"):
41+
dense = nml.get(field)
42+
out[field] = {}
43+
if not isinstance(dense, list):
44+
continue
45+
for m, row in enumerate(dense):
46+
for j, value in enumerate(row or []):
47+
if value:
48+
out[field][(j - ntor, m)] = float(value)
49+
theta = np.linspace(0.0, 2.0 * np.pi, 512, endpoint=False)
50+
51+
def at_zeta0(field, derivative=False):
52+
total = np.zeros_like(theta)
53+
for (_, m), c in out[field].items():
54+
if field in ("rbc", "zbc"):
55+
total += -c * m * np.sin(m * theta) if derivative else c * np.cos(m * theta)
56+
else:
57+
total += c * m * np.cos(m * theta) if derivative else c * np.sin(m * theta)
58+
return total
59+
60+
area = np.sum((at_zeta0("rbc") + at_zeta0("rbs")) * (at_zeta0("zbc", True) + at_zeta0("zbs", True)))
61+
out["flipped"] = area < 0
62+
if out["flipped"]:
63+
# theta -> -theta: (n, m) -> (-n, m) for m > 0; sine coefficients change sign
64+
for field in ("rbc", "rbs", "zbc", "zbs"):
65+
sign = -1.0 if field in ("rbs", "zbs") else 1.0
66+
out[field] = {(-n if m else n, m): (sign * c if m else c) for (n, m), c in out[field].items()}
67+
return out
68+
69+
70+
def convert(source: Path, destination: Path) -> None:
71+
try:
72+
nml = _dense_vmec_namelist(source)
73+
except ValueError as exc:
74+
_mark_unsupported(destination, str(exc))
75+
if int(nml.get("ncurr", 0)) == 1:
76+
_mark_unsupported(destination, "KIN6D prescribes iota; current-constrained inputs (NCURR = 1) are not supported")
77+
for key, neutral in (("bloat", 1.0), ("spres_ped", 1.0)):
78+
if abs(float(nml.get(key, neutral))) != neutral:
79+
_mark_unsupported(destination, f"KIN6D does not take {key.upper()} = {nml[key]}")
80+
try:
81+
scale = float(nml.get("pres_scale", 1.0))
82+
pressure = scale * _monomials(nml, "pmass", "am") if scale else np.zeros(1)
83+
iota = _monomials(nml, "piota", "ai")
84+
except NotImplementedError as exc:
85+
_mark_unsupported(destination, str(exc))
86+
if bool(nml.get("lrfp", False)):
87+
_mark_unsupported(destination, "KIN6D prescribes iota, not the safety factor of LRFP = T")
88+
boundary = _boundary(nml)
89+
if boundary["flipped"]:
90+
iota = -iota
91+
phiedge = abs(float(nml.get("phiedge", 1.0)))
92+
mpol = max([m for f in ("rbc", "rbs", "zbc", "zbs") for (_, m) in boundary[f]] + [1])
93+
ntor = max([abs(n) for f in ("rbc", "rbs", "zbc", "zbs") for (n, _) in boundary[f]] + [0])
94+
lines = [
95+
"&nested_input",
96+
f" nfp = {int(nml.get('nfp', 1))}, mpol = {mpol}, ntor = {ntor}, phiedge = {phiedge!r},",
97+
]
98+
for field in ("rbc", "rbs", "zbc", "zbs"):
99+
lines += [f" {field}({n},{m}) = {float(c)!r}," for (n, m), c in sorted(boundary[field].items())]
100+
lines += [f" iota({k}) = {float(c)!r}," for k, c in enumerate(iota) if c]
101+
lines += [f" pressure({k}) = {float(c)!r}," for k, c in enumerate(pressure) if c]
102+
# VMEC's boundary angle is not a field-line angle, so the boundary field angle is free
103+
lines += [" boundary_nodes = 16, degree = 3, angle_mpol = 4, tolerance = 1e-8, max_iterations = 400,", "/"]
104+
destination.write_text("\n".join(lines) + "\n")
105+
106+
107+
def main() -> int:
108+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
109+
parser.add_argument("input", type=Path)
110+
parser.add_argument("namelist", type=Path)
111+
args = parser.parse_args()
112+
convert(args.input, args.namelist)
113+
return 0
114+
115+
116+
if __name__ == "__main__":
117+
raise SystemExit(main())

0 commit comments

Comments
 (0)