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.
Files changed (52) hide show
  1. dkx/__init__.py +216 -0
  2. dkx/__main__.py +8 -0
  3. dkx/ambipolar.py +334 -0
  4. dkx/api.py +601 -0
  5. dkx/batch.py +509 -0
  6. dkx/bounce_averaged.py +523 -0
  7. dkx/cli.py +1362 -0
  8. dkx/collisions.py +1451 -0
  9. dkx/compare.py +871 -0
  10. dkx/console.py +355 -0
  11. dkx/constants.py +185 -0
  12. dkx/drift_kinetic.py +2473 -0
  13. dkx/er.py +881 -0
  14. dkx/impurity.py +606 -0
  15. dkx/input_compat.py +460 -0
  16. dkx/inputs.py +621 -0
  17. dkx/io.py +337 -0
  18. dkx/magnetic_geometry.py +1946 -0
  19. dkx/moments.py +1328 -0
  20. dkx/momentum_correction.py +529 -0
  21. dkx/monoenergetic.py +980 -0
  22. dkx/namelist.py +207 -0
  23. dkx/paths.py +102 -0
  24. dkx/phase_space.py +1532 -0
  25. dkx/phi1.py +479 -0
  26. dkx/plotting.py +266 -0
  27. dkx/profiling.py +177 -0
  28. dkx/run.py +817 -0
  29. dkx/sensitivity.py +801 -0
  30. dkx/shaing_callen.py +286 -0
  31. dkx/solve.py +1798 -0
  32. dkx/solver_trace.py +161 -0
  33. dkx/species.py +261 -0
  34. dkx/validation/__init__.py +5 -0
  35. dkx/validation/artifacts.py +3346 -0
  36. dkx/validation/data_fetch.py +193 -0
  37. dkx/validation/equilibria_manifest.json +39 -0
  38. dkx/validation/fortran.py +345 -0
  39. dkx/validation/release.py +2347 -0
  40. dkx/variational.py +283 -0
  41. dkx/workflows/__init__.py +23 -0
  42. dkx/workflows/geometry_adapters.py +963 -0
  43. dkx/workflows/optimization.py +2021 -0
  44. dkx/workflows/scans.py +392 -0
  45. dkx/writer.py +1595 -0
  46. dkx/xgrid.py +271 -0
  47. dkx-2.0.0.dist-info/METADATA +293 -0
  48. dkx-2.0.0.dist-info/RECORD +52 -0
  49. dkx-2.0.0.dist-info/WHEEL +5 -0
  50. dkx-2.0.0.dist-info/entry_points.txt +2 -0
  51. dkx-2.0.0.dist-info/licenses/LICENSE +21 -0
  52. 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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
8
+
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