dkx 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dkx/__init__.py +216 -0
- dkx/__main__.py +8 -0
- dkx/ambipolar.py +334 -0
- dkx/api.py +601 -0
- dkx/batch.py +509 -0
- dkx/bounce_averaged.py +523 -0
- dkx/cli.py +1362 -0
- dkx/collisions.py +1451 -0
- dkx/compare.py +871 -0
- dkx/console.py +355 -0
- dkx/constants.py +185 -0
- dkx/drift_kinetic.py +2473 -0
- dkx/er.py +881 -0
- dkx/impurity.py +606 -0
- dkx/input_compat.py +460 -0
- dkx/inputs.py +621 -0
- dkx/io.py +337 -0
- dkx/magnetic_geometry.py +1946 -0
- dkx/moments.py +1328 -0
- dkx/momentum_correction.py +529 -0
- dkx/monoenergetic.py +980 -0
- dkx/namelist.py +207 -0
- dkx/paths.py +102 -0
- dkx/phase_space.py +1532 -0
- dkx/phi1.py +479 -0
- dkx/plotting.py +266 -0
- dkx/profiling.py +177 -0
- dkx/run.py +817 -0
- dkx/sensitivity.py +801 -0
- dkx/shaing_callen.py +286 -0
- dkx/solve.py +1798 -0
- dkx/solver_trace.py +161 -0
- dkx/species.py +261 -0
- dkx/validation/__init__.py +5 -0
- dkx/validation/artifacts.py +3346 -0
- dkx/validation/data_fetch.py +193 -0
- dkx/validation/equilibria_manifest.json +39 -0
- dkx/validation/fortran.py +345 -0
- dkx/validation/release.py +2347 -0
- dkx/variational.py +283 -0
- dkx/workflows/__init__.py +23 -0
- dkx/workflows/geometry_adapters.py +963 -0
- dkx/workflows/optimization.py +2021 -0
- dkx/workflows/scans.py +392 -0
- dkx/writer.py +1595 -0
- dkx/xgrid.py +271 -0
- dkx-2.0.0.dist-info/METADATA +293 -0
- dkx-2.0.0.dist-info/RECORD +52 -0
- dkx-2.0.0.dist-info/WHEEL +5 -0
- dkx-2.0.0.dist-info/entry_points.txt +2 -0
- dkx-2.0.0.dist-info/licenses/LICENSE +21 -0
- dkx-2.0.0.dist-info/top_level.txt +1 -0
dkx/__init__.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Differentiable neoclassical transport solvers and SFINCS-style outputs in JAX.
|
|
2
|
+
|
|
3
|
+
The public CLI and Python APIs are maintained as standalone research tools while
|
|
4
|
+
retaining release-gated comparisons against SFINCS Fortran v3 for trust building.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# Enable host-device parallelism and a default JAX compilation cache for repeated
|
|
10
|
+
# CLI invocations unless the user explicitly disables it. This improves cold-start
|
|
11
|
+
# performance without requiring environment configuration.
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
# Suppress low-value XLA/PjRt C++ warning chatter by default. Users can still
|
|
16
|
+
# override this before importing dkx if they need backend debug logs.
|
|
17
|
+
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2")
|
|
18
|
+
|
|
19
|
+
_distributed_runtime_initialized = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def initialize_distributed_runtime_from_env() -> bool:
|
|
23
|
+
"""Best-effort JAX multi-host bootstrap from DKX_* env vars.
|
|
24
|
+
|
|
25
|
+
This helper is called at import time for env-driven workflows and again by the
|
|
26
|
+
CLI after parsing explicit multi-host flags. Repeated calls are safe.
|
|
27
|
+
"""
|
|
28
|
+
global _distributed_runtime_initialized
|
|
29
|
+
if _distributed_runtime_initialized:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
distributed_env = os.environ.get("DKX_DISTRIBUTED", "").strip().lower()
|
|
33
|
+
if distributed_env not in {"1", "true", "yes", "on"}:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import jax.distributed as _jax_distributed # noqa: PLC0415
|
|
38
|
+
|
|
39
|
+
process_id_env = os.environ.get("DKX_PROCESS_ID", "").strip()
|
|
40
|
+
process_count_env = os.environ.get("DKX_PROCESS_COUNT", "").strip()
|
|
41
|
+
coord_addr = os.environ.get("DKX_COORDINATOR_ADDRESS", "").strip()
|
|
42
|
+
coord_port_env = os.environ.get("DKX_COORDINATOR_PORT", "").strip()
|
|
43
|
+
|
|
44
|
+
process_id = int(process_id_env) if process_id_env else 0
|
|
45
|
+
process_count = int(process_count_env) if process_count_env else 1
|
|
46
|
+
coord_port = int(coord_port_env) if coord_port_env else 1234
|
|
47
|
+
|
|
48
|
+
if not coord_addr:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
_jax_distributed.initialize(
|
|
52
|
+
coordinator_address=coord_addr,
|
|
53
|
+
coordinator_port=coord_port,
|
|
54
|
+
num_processes=process_count,
|
|
55
|
+
process_id=process_id,
|
|
56
|
+
)
|
|
57
|
+
_distributed_runtime_initialized = True
|
|
58
|
+
return True
|
|
59
|
+
except Exception:
|
|
60
|
+
# Best-effort: avoid hard failures when distributed runtime is unavailable.
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Optional JAX multi-host bootstrap (must run before any JAX device use).
|
|
65
|
+
initialize_distributed_runtime_from_env()
|
|
66
|
+
|
|
67
|
+
# High-level cores knob: set this before importing JAX to request N CPU devices
|
|
68
|
+
# and enable auto-sharding by default.
|
|
69
|
+
_cores_env = os.environ.get("DKX_CORES", "").strip()
|
|
70
|
+
if _cores_env:
|
|
71
|
+
try:
|
|
72
|
+
_cores_val = int(_cores_env)
|
|
73
|
+
except ValueError:
|
|
74
|
+
_cores_val = 0
|
|
75
|
+
if _cores_val > 0:
|
|
76
|
+
_threads_env = os.environ.get("DKX_XLA_THREADS", "").strip().lower()
|
|
77
|
+
if _threads_env in {"1", "true", "yes", "on"}:
|
|
78
|
+
_xla_flags = os.environ.get("XLA_FLAGS", "")
|
|
79
|
+
if "--xla_cpu_parallelism_threads" not in _xla_flags:
|
|
80
|
+
flag = f"--xla_cpu_parallelism_threads={_cores_val}"
|
|
81
|
+
os.environ["XLA_FLAGS"] = f"{_xla_flags} {flag}".strip()
|
|
82
|
+
shard_env = os.environ.get("DKX_SHARD", "").strip().lower()
|
|
83
|
+
if _cores_val > 1 and shard_env not in {"0", "false", "no", "off"}:
|
|
84
|
+
os.environ.setdefault("DKX_CPU_DEVICES", str(_cores_val))
|
|
85
|
+
os.environ.setdefault("DKX_MATVEC_SHARD_AXIS", "auto")
|
|
86
|
+
os.environ.setdefault("DKX_AUTO_SHARD", "1")
|
|
87
|
+
|
|
88
|
+
# Allow users to request multiple CPU devices for JAX SPMD sharded-JIT on host platforms.
|
|
89
|
+
# This must be set before importing JAX.
|
|
90
|
+
_cpu_devices_env = os.environ.get("DKX_CPU_DEVICES", "").strip()
|
|
91
|
+
if _cpu_devices_env:
|
|
92
|
+
try:
|
|
93
|
+
_cpu_devices = int(_cpu_devices_env)
|
|
94
|
+
except ValueError:
|
|
95
|
+
_cpu_devices = 0
|
|
96
|
+
if _cpu_devices > 0:
|
|
97
|
+
_xla_flags = os.environ.get("XLA_FLAGS", "")
|
|
98
|
+
if "--xla_force_host_platform_device_count" not in _xla_flags:
|
|
99
|
+
flag = f"--xla_force_host_platform_device_count={_cpu_devices}"
|
|
100
|
+
os.environ["XLA_FLAGS"] = f"{_xla_flags} {flag}".strip()
|
|
101
|
+
|
|
102
|
+
_disable_cache = os.environ.get("DKX_DISABLE_COMPILATION_CACHE", "").strip().lower()
|
|
103
|
+
if _disable_cache not in {"1", "true", "yes", "on"}:
|
|
104
|
+
if not os.environ.get("JAX_COMPILATION_CACHE_DIR", "").strip():
|
|
105
|
+
def _is_writable_dir(path: str) -> bool:
|
|
106
|
+
try:
|
|
107
|
+
test_path = os.path.join(path, ".dkx_write_test")
|
|
108
|
+
with open(test_path, "wb") as f:
|
|
109
|
+
f.write(b"")
|
|
110
|
+
os.remove(test_path)
|
|
111
|
+
return True
|
|
112
|
+
except OSError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
cache_override = os.environ.get("DKX_COMPILATION_CACHE_DIR", "").strip()
|
|
116
|
+
if cache_override:
|
|
117
|
+
default_cache_dir = cache_override
|
|
118
|
+
else:
|
|
119
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME", "").strip()
|
|
120
|
+
if xdg_cache:
|
|
121
|
+
default_cache_dir = os.path.join(xdg_cache, "dkx", "jax_compilation_cache")
|
|
122
|
+
else:
|
|
123
|
+
default_cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "dkx", "jax_compilation_cache")
|
|
124
|
+
try:
|
|
125
|
+
os.makedirs(default_cache_dir, exist_ok=True)
|
|
126
|
+
except OSError:
|
|
127
|
+
default_cache_dir = os.path.join(tempfile.gettempdir(), "dkx", "jax_compilation_cache")
|
|
128
|
+
try:
|
|
129
|
+
os.makedirs(default_cache_dir, exist_ok=True)
|
|
130
|
+
except OSError:
|
|
131
|
+
default_cache_dir = ""
|
|
132
|
+
if default_cache_dir and (not _is_writable_dir(default_cache_dir)):
|
|
133
|
+
# Some environments (CI sandboxes, read-only homes) can create the directory but
|
|
134
|
+
# cannot write compilation entries. Fall back to a tempdir cache to avoid noisy
|
|
135
|
+
# warnings and degraded cold-start performance.
|
|
136
|
+
default_cache_dir = os.path.join(tempfile.gettempdir(), "dkx", "jax_compilation_cache")
|
|
137
|
+
try:
|
|
138
|
+
os.makedirs(default_cache_dir, exist_ok=True)
|
|
139
|
+
except OSError:
|
|
140
|
+
default_cache_dir = ""
|
|
141
|
+
if default_cache_dir and (not _is_writable_dir(default_cache_dir)):
|
|
142
|
+
default_cache_dir = ""
|
|
143
|
+
if default_cache_dir:
|
|
144
|
+
os.environ["JAX_COMPILATION_CACHE_DIR"] = default_cache_dir
|
|
145
|
+
os.environ.setdefault("JAX_PERSISTENT_CACHE_MIN_COMPILE_TIME_SECS", "0")
|
|
146
|
+
os.environ.setdefault("JAX_PERSISTENT_CACHE_MIN_ENTRY_SIZE_BYTES", "0")
|
|
147
|
+
|
|
148
|
+
# SFINCS parity fixtures and most scientific use-cases rely on float64 accuracy.
|
|
149
|
+
# Set this as early as possible on package import.
|
|
150
|
+
try:
|
|
151
|
+
from jax import config as _jax_config # noqa: PLC0415
|
|
152
|
+
|
|
153
|
+
_jax_config.update("jax_enable_x64", True)
|
|
154
|
+
# Enable the persistent compilation cache via the current jax config API.
|
|
155
|
+
# The JAX_COMPILATION_CACHE_DIR env var set above only takes effect if jax
|
|
156
|
+
# reads its config for the first time here; when the user imported jax
|
|
157
|
+
# before dkx that ordering is already lost, so set the flags
|
|
158
|
+
# explicitly (works regardless of import order). The retired
|
|
159
|
+
# jax.experimental.compilation_cache.set_cache_dir was removed in recent jax
|
|
160
|
+
# (e.g. 0.10.x) and silently no-ops, so it must not be relied on. Forcing
|
|
161
|
+
# the min-compile-time / min-entry-size thresholds to zero makes even the
|
|
162
|
+
# tiny fast-compiling kernels cacheable.
|
|
163
|
+
_cache_dir = os.environ.get("JAX_COMPILATION_CACHE_DIR", "").strip()
|
|
164
|
+
if _cache_dir:
|
|
165
|
+
_jax_config.update("jax_compilation_cache_dir", _cache_dir)
|
|
166
|
+
# Mirror the thresholds set above as env-var defaults (respecting any
|
|
167
|
+
# explicit user override) via config so they also apply when jax was
|
|
168
|
+
# imported before dkx and never read the env vars.
|
|
169
|
+
try:
|
|
170
|
+
_jax_config.update(
|
|
171
|
+
"jax_persistent_cache_min_compile_time_secs",
|
|
172
|
+
float(os.environ.get("JAX_PERSISTENT_CACHE_MIN_COMPILE_TIME_SECS", "0")),
|
|
173
|
+
)
|
|
174
|
+
_jax_config.update(
|
|
175
|
+
"jax_persistent_cache_min_entry_size_bytes",
|
|
176
|
+
int(os.environ.get("JAX_PERSISTENT_CACHE_MIN_ENTRY_SIZE_BYTES", "0")),
|
|
177
|
+
)
|
|
178
|
+
except ValueError:
|
|
179
|
+
pass
|
|
180
|
+
except Exception:
|
|
181
|
+
# Keep import lightweight for tooling that inspects the package without JAX.
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
from .api import ( # noqa: E402
|
|
185
|
+
BenchmarkReport,
|
|
186
|
+
GeometryState,
|
|
187
|
+
GridState,
|
|
188
|
+
OperatorState,
|
|
189
|
+
OutputSchema,
|
|
190
|
+
PreconditionerState,
|
|
191
|
+
SolveInputs,
|
|
192
|
+
SolverResult,
|
|
193
|
+
TransportResult,
|
|
194
|
+
read_output,
|
|
195
|
+
run_ambipolar_brent,
|
|
196
|
+
write_output,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
__all__ = [
|
|
200
|
+
"BenchmarkReport",
|
|
201
|
+
"GeometryState",
|
|
202
|
+
"GridState",
|
|
203
|
+
"OperatorState",
|
|
204
|
+
"OutputSchema",
|
|
205
|
+
"PreconditionerState",
|
|
206
|
+
"SolveInputs",
|
|
207
|
+
"SolverResult",
|
|
208
|
+
"TransportResult",
|
|
209
|
+
"__version__",
|
|
210
|
+
"initialize_distributed_runtime_from_env",
|
|
211
|
+
"read_output",
|
|
212
|
+
"run_ambipolar_brent",
|
|
213
|
+
"write_output",
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
__version__ = "2.0.0"
|
dkx/__main__.py
ADDED
dkx/ambipolar.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import pickle
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .io import read_sfincs_h5
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AmbipolarSolveResult:
|
|
14
|
+
var_name: str
|
|
15
|
+
var_values: np.ndarray # (N,)
|
|
16
|
+
er_values: np.ndarray # (N,)
|
|
17
|
+
radial_currents: np.ndarray # (N,)
|
|
18
|
+
roots_var: np.ndarray # (Nr,)
|
|
19
|
+
roots_er: np.ndarray # (Nr,)
|
|
20
|
+
root_types: list[str]
|
|
21
|
+
outputs_labels: list[str]
|
|
22
|
+
outputs_by_run: np.ndarray # (N, Q)
|
|
23
|
+
outputs_at_roots: list[np.ndarray] # length Q, each (Nr,)
|
|
24
|
+
radius_wish: float | None = None
|
|
25
|
+
radius_actual: float | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fortran_bool_to_py(v) -> bool:
|
|
29
|
+
# `sfincsOutput.h5` uses v3 integer-to-represent-true/false convention.
|
|
30
|
+
if isinstance(v, (bool, np.bool_)):
|
|
31
|
+
return bool(v)
|
|
32
|
+
try:
|
|
33
|
+
return int(np.asarray(v).reshape(())[()]) > 0
|
|
34
|
+
except Exception: # noqa: BLE001
|
|
35
|
+
return bool(v)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _as_float(v) -> float:
|
|
39
|
+
return float(np.asarray(v, dtype=np.float64).reshape(()))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def radial_current_from_output(data: dict) -> float:
|
|
43
|
+
"""Compute the net radial current used by upstream `sfincsScanPlot_2`.
|
|
44
|
+
|
|
45
|
+
Upstream convention:
|
|
46
|
+
- if includePhi1: j_psi = sum_s Z_s * particleFlux_vd_rHat[s, -1]
|
|
47
|
+
- else: j_psi = sum_s Z_s * particleFlux_vm_rHat[s, -1]
|
|
48
|
+
"""
|
|
49
|
+
include_phi1 = _fortran_bool_to_py(data.get("includePhi1", False))
|
|
50
|
+
z_s = np.asarray(data["Zs"], dtype=np.float64).reshape((-1,))
|
|
51
|
+
if include_phi1 and "particleFlux_vd_rHat" in data:
|
|
52
|
+
pf = np.asarray(data["particleFlux_vd_rHat"], dtype=np.float64)
|
|
53
|
+
else:
|
|
54
|
+
pf = np.asarray(data["particleFlux_vm_rHat"], dtype=np.float64)
|
|
55
|
+
# Expect shape (S, NIterations) (or (S,) for some static outputs); use last iteration if needed.
|
|
56
|
+
if pf.ndim == 2:
|
|
57
|
+
pf_last = pf[:, -1]
|
|
58
|
+
else:
|
|
59
|
+
pf_last = pf.reshape((-1,))
|
|
60
|
+
return float(np.sum(z_s * pf_last))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _infer_var_name_from_scan_input(scan_input: Path) -> str:
|
|
64
|
+
# `run_er_scan` writes comment lines like: "!ss ErMin = ..." and "!ss ErMax = ..."
|
|
65
|
+
txt = scan_input.read_text()
|
|
66
|
+
for k in ("Er", "dPhiHatdpsiHat", "dPhiHatdpsiN", "dPhiHatdrHat", "dPhiHatdrN"):
|
|
67
|
+
if f"!ss {k}Min" in txt or f"!ss {k}Max" in txt:
|
|
68
|
+
return k
|
|
69
|
+
# Fallback: assume Er.
|
|
70
|
+
return "Er"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scanplot2_labels(*, n_species: int, include_phi1: bool) -> list[str]:
|
|
74
|
+
if n_species == 1:
|
|
75
|
+
if include_phi1:
|
|
76
|
+
return [
|
|
77
|
+
"FSABFlow",
|
|
78
|
+
"particleFlux_vm_rHat",
|
|
79
|
+
"particleFlux_vd_rHat",
|
|
80
|
+
"heatFlux_vm_rHat",
|
|
81
|
+
"heatFlux_withoutPhi1_rHat",
|
|
82
|
+
"FSABjHat",
|
|
83
|
+
"radial current",
|
|
84
|
+
]
|
|
85
|
+
return [
|
|
86
|
+
"FSABFlow",
|
|
87
|
+
"particleFlux_vm_rHat",
|
|
88
|
+
"heatFlux_vm_rHat",
|
|
89
|
+
"source 1",
|
|
90
|
+
"source 2",
|
|
91
|
+
"FSABjHat",
|
|
92
|
+
"radial current",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
labels: list[str] = []
|
|
96
|
+
for i in range(1, n_species + 1):
|
|
97
|
+
labels.append(f"FSABFlow (species {i})")
|
|
98
|
+
labels.append(f"particleFlux rHat (species {i})")
|
|
99
|
+
labels.append(f"heatFlux rHat (species {i})")
|
|
100
|
+
labels.append("FSABjHat")
|
|
101
|
+
labels.append("radial current")
|
|
102
|
+
return labels
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _scanplot2_outputs_for_run(data: dict) -> np.ndarray:
|
|
106
|
+
n_species = int(np.asarray(data["Nspecies"]))
|
|
107
|
+
include_phi1 = _fortran_bool_to_py(data.get("includePhi1", False))
|
|
108
|
+
# Many datasets are (S, NIterations), some are (NIterations,), some are scalars.
|
|
109
|
+
def _last(v):
|
|
110
|
+
arr = np.asarray(v, dtype=np.float64)
|
|
111
|
+
if arr.ndim == 0:
|
|
112
|
+
return arr.reshape((1,))
|
|
113
|
+
if arr.ndim == 1:
|
|
114
|
+
return arr[-1:]
|
|
115
|
+
if arr.ndim == 2:
|
|
116
|
+
return arr[:, -1]
|
|
117
|
+
raise ValueError(f"Unexpected dataset rank {arr.ndim}")
|
|
118
|
+
|
|
119
|
+
out: list[float] = []
|
|
120
|
+
if n_species == 1:
|
|
121
|
+
fsab_flow = float(_last(data["FSABFlow"])[0])
|
|
122
|
+
pf_vm = float(_last(data["particleFlux_vm_rHat"])[0])
|
|
123
|
+
hf_vm = float(_last(data["heatFlux_vm_rHat"])[0])
|
|
124
|
+
fsab_j = float(_last(data["FSABjHat"])[0]) if "FSABjHat" in data else float(_as_float(data.get("FSABjHat", 0.0)))
|
|
125
|
+
if include_phi1 and "particleFlux_vd_rHat" in data:
|
|
126
|
+
pf_vd = float(_last(data["particleFlux_vd_rHat"])[0])
|
|
127
|
+
hf_wo = float(_last(data["heatFlux_withoutPhi1_rHat"])[0])
|
|
128
|
+
out += [fsab_flow, pf_vm, pf_vd, hf_vm, hf_wo, fsab_j, radial_current_from_output(data)]
|
|
129
|
+
else:
|
|
130
|
+
src = np.asarray(data.get("sources", np.zeros((2,), dtype=np.float64)), dtype=np.float64)
|
|
131
|
+
# sources shape varies across fixtures; take last iteration if present.
|
|
132
|
+
if src.ndim == 1:
|
|
133
|
+
s0, s1 = (float(src[0]), float(src[1])) if src.size >= 2 else (float(src[0]), 0.0)
|
|
134
|
+
elif src.ndim == 2:
|
|
135
|
+
s0 = float(src[0, -1])
|
|
136
|
+
s1 = float(src[1, -1])
|
|
137
|
+
else:
|
|
138
|
+
s0 = s1 = 0.0
|
|
139
|
+
out += [fsab_flow, pf_vm, hf_vm, s0, s1, fsab_j, radial_current_from_output(data)]
|
|
140
|
+
return np.asarray(out, dtype=np.float64)
|
|
141
|
+
|
|
142
|
+
# Multi-species:
|
|
143
|
+
fsab_flow = np.asarray(_last(data["FSABFlow"]), dtype=np.float64).reshape((n_species,))
|
|
144
|
+
if include_phi1 and "particleFlux_vd_rHat" in data:
|
|
145
|
+
pf = np.asarray(_last(data["particleFlux_vd_rHat"]), dtype=np.float64).reshape((n_species,))
|
|
146
|
+
hf = np.asarray(_last(data.get("heatFlux_vd_rHat", data.get("heatFlux_vm_rHat"))), dtype=np.float64).reshape((n_species,))
|
|
147
|
+
else:
|
|
148
|
+
pf = np.asarray(_last(data["particleFlux_vm_rHat"]), dtype=np.float64).reshape((n_species,))
|
|
149
|
+
hf = np.asarray(_last(data["heatFlux_vm_rHat"]), dtype=np.float64).reshape((n_species,))
|
|
150
|
+
for i in range(n_species):
|
|
151
|
+
out += [float(fsab_flow[i]), float(pf[i]), float(hf[i])]
|
|
152
|
+
fsab_j = float(_last(data["FSABjHat"])[0]) if "FSABjHat" in data and np.asarray(data["FSABjHat"]).ndim == 1 else float(_as_float(data.get("FSABjHat", 0.0)))
|
|
153
|
+
out += [fsab_j, radial_current_from_output(data)]
|
|
154
|
+
return np.asarray(out, dtype=np.float64)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def solve_ambipolar_from_scan_dir(
|
|
158
|
+
*,
|
|
159
|
+
scan_dir: Path,
|
|
160
|
+
write_pickle: bool = True,
|
|
161
|
+
write_json: bool = True,
|
|
162
|
+
n_fine: int = 500,
|
|
163
|
+
) -> AmbipolarSolveResult:
|
|
164
|
+
"""Compute ambipolar roots from an existing Er scan directory.
|
|
165
|
+
|
|
166
|
+
The directory must look like an upstream scanType=2 directory:
|
|
167
|
+
- `scan_dir/input.namelist` exists and includes the `!ss` metadata written by `run_er_scan`.
|
|
168
|
+
- subdirectories contain `sfincsOutput.h5`.
|
|
169
|
+
|
|
170
|
+
This routine writes `ambipolarSolutions.dat` in a format compatible with upstream `sfincsScanPlot_5`.
|
|
171
|
+
"""
|
|
172
|
+
scan_dir = Path(scan_dir).resolve()
|
|
173
|
+
scan_input = scan_dir / "input.namelist"
|
|
174
|
+
if not scan_input.exists():
|
|
175
|
+
raise FileNotFoundError(f"Missing scan input.namelist: {scan_input}")
|
|
176
|
+
|
|
177
|
+
var_name = _infer_var_name_from_scan_input(scan_input)
|
|
178
|
+
|
|
179
|
+
run_dirs = sorted([p for p in scan_dir.iterdir() if p.is_dir() and p.name.startswith(var_name)])
|
|
180
|
+
if not run_dirs:
|
|
181
|
+
raise FileNotFoundError(f"No run directories found under {scan_dir} with prefix {var_name}")
|
|
182
|
+
|
|
183
|
+
records: list[tuple[float, float, float, dict]] = []
|
|
184
|
+
for rd in run_dirs:
|
|
185
|
+
out_h5 = rd / "sfincsOutput.h5"
|
|
186
|
+
if not out_h5.exists():
|
|
187
|
+
continue
|
|
188
|
+
d = read_sfincs_h5(out_h5)
|
|
189
|
+
if int(np.asarray(d.get("RHSMode", 1))) != 1:
|
|
190
|
+
continue
|
|
191
|
+
v = _as_float(d[var_name]) if var_name in d else float("nan")
|
|
192
|
+
er = _as_float(d["Er"]) if "Er" in d else v
|
|
193
|
+
jpsi = radial_current_from_output(d)
|
|
194
|
+
records.append((v, er, jpsi, d))
|
|
195
|
+
|
|
196
|
+
if len(records) < 2:
|
|
197
|
+
raise RuntimeError("Need at least 2 completed runs to attempt an ambipolarity solve.")
|
|
198
|
+
|
|
199
|
+
# Sort by scan variable (as upstream does).
|
|
200
|
+
records.sort(key=lambda t: t[0])
|
|
201
|
+
var_vals = np.asarray([r[0] for r in records], dtype=np.float64)
|
|
202
|
+
er_vals = np.asarray([r[1] for r in records], dtype=np.float64)
|
|
203
|
+
jpsi_vals = np.asarray([r[2] for r in records], dtype=np.float64)
|
|
204
|
+
|
|
205
|
+
# Determine which flux variant was used (for labels), based on includePhi1 in the last run.
|
|
206
|
+
include_phi1 = _fortran_bool_to_py(records[-1][3].get("includePhi1", False))
|
|
207
|
+
n_species = int(np.asarray(records[-1][3]["Nspecies"]))
|
|
208
|
+
labels = _scanplot2_labels(n_species=n_species, include_phi1=include_phi1)
|
|
209
|
+
outputs_by_run = np.stack([_scanplot2_outputs_for_run(r[3]) for r in records], axis=0) # (N, Q)
|
|
210
|
+
|
|
211
|
+
# Interpolator choice:
|
|
212
|
+
from scipy.interpolate import PchipInterpolator, interp1d # noqa: PLC0415
|
|
213
|
+
from scipy.optimize import brentq # noqa: PLC0415
|
|
214
|
+
|
|
215
|
+
if var_vals.size < 3:
|
|
216
|
+
interpolator = interp1d(var_vals, jpsi_vals, kind="linear")
|
|
217
|
+
|
|
218
|
+
def quantity_interp(y):
|
|
219
|
+
return interp1d(var_vals, y, kind="linear")
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
try:
|
|
223
|
+
interpolator = PchipInterpolator(var_vals, jpsi_vals)
|
|
224
|
+
|
|
225
|
+
def quantity_interp(y):
|
|
226
|
+
return PchipInterpolator(var_vals, y)
|
|
227
|
+
|
|
228
|
+
except Exception:
|
|
229
|
+
interpolator = interp1d(var_vals, jpsi_vals, kind="linear")
|
|
230
|
+
|
|
231
|
+
def quantity_interp(y):
|
|
232
|
+
return interp1d(var_vals, y, kind="linear")
|
|
233
|
+
|
|
234
|
+
roots_var: list[float] = []
|
|
235
|
+
if float(np.max(jpsi_vals)) > 0.0 and float(np.min(jpsi_vals)) < 0.0:
|
|
236
|
+
fine = np.linspace(float(np.min(var_vals)), float(np.max(var_vals)), num=int(n_fine), dtype=np.float64)
|
|
237
|
+
j_fine = np.asarray(interpolator(fine), dtype=np.float64)
|
|
238
|
+
pos = j_fine > 0
|
|
239
|
+
flips = pos[:-1] != pos[1:]
|
|
240
|
+
for idx, v in enumerate(flips):
|
|
241
|
+
if not v:
|
|
242
|
+
continue
|
|
243
|
+
a = float(fine[idx])
|
|
244
|
+
b = float(fine[idx + 1])
|
|
245
|
+
try:
|
|
246
|
+
roots_var.append(float(brentq(interpolator, a, b)))
|
|
247
|
+
except Exception:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
roots_var_arr = np.sort(np.asarray(roots_var, dtype=np.float64))
|
|
251
|
+
# Conversion to Er: Er = var * (Er/var).
|
|
252
|
+
conversion_to_er = 1.0
|
|
253
|
+
if var_name != "Er":
|
|
254
|
+
# Use the ratio from the last run (they are constant for a given setup).
|
|
255
|
+
v_last = float(var_vals[-1])
|
|
256
|
+
er_last = float(er_vals[-1])
|
|
257
|
+
conversion_to_er = float(er_last / v_last)
|
|
258
|
+
roots_er_arr = roots_var_arr * float(conversion_to_er)
|
|
259
|
+
|
|
260
|
+
# Root typing matches upstream heuristic.
|
|
261
|
+
if roots_er_arr.size == 1:
|
|
262
|
+
root_types = ["electron" if float(roots_er_arr[0]) > 0.0 else "ion"]
|
|
263
|
+
elif roots_er_arr.size == 3:
|
|
264
|
+
root_types = ["ion", "unstable", "electron"]
|
|
265
|
+
else:
|
|
266
|
+
root_types = ["unknown"] * int(roots_er_arr.size)
|
|
267
|
+
|
|
268
|
+
outputs_at_roots: list[np.ndarray] = []
|
|
269
|
+
if roots_var_arr.size:
|
|
270
|
+
for q in range(outputs_by_run.shape[1]):
|
|
271
|
+
qi = quantity_interp(outputs_by_run[:, q])
|
|
272
|
+
outputs_at_roots.append(np.asarray(qi(roots_var_arr), dtype=np.float64))
|
|
273
|
+
else:
|
|
274
|
+
outputs_at_roots = [np.zeros((0,), dtype=np.float64) for _ in range(outputs_by_run.shape[1])]
|
|
275
|
+
|
|
276
|
+
# Estimate radii from the last run:
|
|
277
|
+
last = records[-1][3]
|
|
278
|
+
radius_wish = None
|
|
279
|
+
radius_actual = None
|
|
280
|
+
for k in ("rN", "rHat", "psiN", "psiHat"):
|
|
281
|
+
if k in last:
|
|
282
|
+
radius_actual = _as_float(last[k])
|
|
283
|
+
break
|
|
284
|
+
# `*_wish` is not always written; keep None if unavailable.
|
|
285
|
+
|
|
286
|
+
result = AmbipolarSolveResult(
|
|
287
|
+
var_name=var_name,
|
|
288
|
+
var_values=var_vals,
|
|
289
|
+
er_values=er_vals,
|
|
290
|
+
radial_currents=jpsi_vals,
|
|
291
|
+
roots_var=roots_var_arr,
|
|
292
|
+
roots_er=roots_er_arr,
|
|
293
|
+
root_types=root_types,
|
|
294
|
+
outputs_labels=labels,
|
|
295
|
+
outputs_by_run=outputs_by_run,
|
|
296
|
+
outputs_at_roots=outputs_at_roots,
|
|
297
|
+
radius_wish=radius_wish,
|
|
298
|
+
radius_actual=radius_actual,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if write_pickle:
|
|
302
|
+
# Minimal pickle format compatible with upstream `sfincsScanPlot_5`.
|
|
303
|
+
payload = {
|
|
304
|
+
"numQuantities": int(outputs_by_run.shape[1]),
|
|
305
|
+
"ylabels": labels,
|
|
306
|
+
"roots": roots_var_arr,
|
|
307
|
+
"root_types": root_types,
|
|
308
|
+
"outputs_ambipolar": outputs_at_roots,
|
|
309
|
+
"radius_wish": radius_wish,
|
|
310
|
+
"radius_actual": radius_actual,
|
|
311
|
+
"nHats": np.asarray(last.get("nHats", np.zeros((0,), dtype=np.float64)), dtype=np.float64).reshape((-1,)),
|
|
312
|
+
"THats": np.asarray(last.get("THats", np.zeros((0,), dtype=np.float64)), dtype=np.float64).reshape((-1,)),
|
|
313
|
+
}
|
|
314
|
+
(scan_dir / "ambipolarSolutions.dat").write_bytes(pickle.dumps(payload))
|
|
315
|
+
|
|
316
|
+
if write_json:
|
|
317
|
+
import json # noqa: PLC0415
|
|
318
|
+
|
|
319
|
+
(scan_dir / "ambipolarSolutions.json").write_text(
|
|
320
|
+
json.dumps(
|
|
321
|
+
{
|
|
322
|
+
"var_name": var_name,
|
|
323
|
+
"roots_var": roots_var_arr.tolist(),
|
|
324
|
+
"roots_er": roots_er_arr.tolist(),
|
|
325
|
+
"root_types": root_types,
|
|
326
|
+
"radial_currents": jpsi_vals.tolist(),
|
|
327
|
+
},
|
|
328
|
+
indent=2,
|
|
329
|
+
sort_keys=True,
|
|
330
|
+
),
|
|
331
|
+
encoding="utf-8",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return result
|