d0fus 2.3.0__tar.gz → 2.3.3__tar.gz
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.
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS.py +31 -3
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_figures.py +515 -10
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_parameterization.py +87 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_physical_functions.py +145 -61
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_radial_build_functions.py +138 -31
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_EXE/D0FUS_genetic.py +287 -29
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_EXE/D0FUS_run.py +11 -20
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_EXE/D0FUS_scan.py +4 -9
- d0fus-2.3.3/D0FUS_EXE/D0FUS_uncertainty.py +652 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/PKG-INFO +9 -1
- {d0fus-2.3.0 → d0fus-2.3.3}/README.md +8 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/d0fus.egg-info/PKG-INFO +9 -1
- {d0fus-2.3.0 → d0fus-2.3.3}/d0fus.egg-info/SOURCES.txt +1 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/pyproject.toml +1 -1
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_cost_data.py +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_cost_functions.py +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/D0FUS_BIB/D0FUS_import.py +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/LICENSE +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/d0fus.egg-info/dependency_links.txt +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/d0fus.egg-info/entry_points.txt +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/d0fus.egg-info/requires.txt +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/d0fus.egg-info/top_level.txt +0 -0
- {d0fus-2.3.0 → d0fus-2.3.3}/setup.cfg +0 -0
|
@@ -20,7 +20,7 @@ sys.path.insert(0, project_root)
|
|
|
20
20
|
|
|
21
21
|
# Import all necessary modules
|
|
22
22
|
from D0FUS_BIB.D0FUS_parameterization import *
|
|
23
|
-
from D0FUS_EXE import D0FUS_scan, D0FUS_run, D0FUS_genetic
|
|
23
|
+
from D0FUS_EXE import D0FUS_scan, D0FUS_run, D0FUS_genetic, D0FUS_uncertainty
|
|
24
24
|
|
|
25
25
|
#%% Mode detection
|
|
26
26
|
|
|
@@ -41,6 +41,15 @@ def detect_mode_from_input(input_file):
|
|
|
41
41
|
with open(input_file, 'r', encoding='utf-8') as f:
|
|
42
42
|
content = f.read()
|
|
43
43
|
|
|
44
|
+
# UNCERTAINTY mode: an [UNCERTAINTY] section, or any tri()/norm()/unif()/envelope()
|
|
45
|
+
# marginal. Checked first so that optional map axes (a = [min, max, n]) do not make
|
|
46
|
+
# the file look like a SCAN.
|
|
47
|
+
if (re.search(r'^\s*\[\s*uncertainty\s*\]', content, re.MULTILINE | re.IGNORECASE)
|
|
48
|
+
or re.search(r'=\s*(tri|norm|unif|envelope)\s*\(', content, re.IGNORECASE)):
|
|
49
|
+
n_uncertain = len(re.findall(r'^\s*\w+\s*=\s*(?:tri|norm|unif|envelope)\s*\(',
|
|
50
|
+
content, re.MULTILINE | re.IGNORECASE))
|
|
51
|
+
return 'uncertainty', n_uncertain
|
|
52
|
+
|
|
44
53
|
# Extract genetic algorithm parameters if present
|
|
45
54
|
genetic_params = {}
|
|
46
55
|
genetic_keywords = {
|
|
@@ -239,11 +248,18 @@ Modes (detected automatically from input file format):
|
|
|
239
248
|
crossover_rate = 0.7 (default: 0.7)
|
|
240
249
|
mutation_rate = 0.2 (default: 0.2)
|
|
241
250
|
|
|
251
|
+
UNCERTAINTY mode: Monte-Carlo robustness study around one design point
|
|
252
|
+
An [UNCERTAINTY] section listing parameter ranges
|
|
253
|
+
Example: H = tri(0.75, 1.5)
|
|
254
|
+
betaN_limit = tri(2.8, 3.6)
|
|
255
|
+
Scaling_Law = envelope(IPB98(y,2) | ITPA20)
|
|
256
|
+
|
|
242
257
|
Detection rules:
|
|
243
258
|
• [min, max] format (2 values) → OPTIMIZATION (need 2+ parameters)
|
|
244
259
|
• [min, max, n] format (3 values) → SCAN (need exactly 2 parameters)
|
|
245
|
-
•
|
|
246
|
-
•
|
|
260
|
+
• [UNCERTAINTY] section or tri()/norm()/unif()/envelope() → UNCERTAINTY
|
|
261
|
+
• No brackets (and no [UNCERTAINTY] section) → RUN
|
|
262
|
+
• Cannot mix scan and optimization formats in same file
|
|
247
263
|
|
|
248
264
|
For help:
|
|
249
265
|
python D0FUS.py --help
|
|
@@ -283,6 +299,9 @@ def select_input_file():
|
|
|
283
299
|
opt_params, genetic_params = params
|
|
284
300
|
param_names = list(opt_params.keys())
|
|
285
301
|
mode_str = f"GENETIC ({len(param_names)} params)"
|
|
302
|
+
elif mode == 'uncertainty':
|
|
303
|
+
mode_str = (f"UNCERTAINTY ({params} params)" if params
|
|
304
|
+
else "UNCERTAINTY (Monte-Carlo)")
|
|
286
305
|
else:
|
|
287
306
|
mode_str = "RUN"
|
|
288
307
|
print(f" {i}. {file.name:<30} [{mode_str}]")
|
|
@@ -389,6 +408,15 @@ def execute_with_mode_detection(input_file):
|
|
|
389
408
|
|
|
390
409
|
# Run genetic optimization with specified or default parameters
|
|
391
410
|
D0FUS_genetic.run_genetic_optimization(input_file, **ga_params)
|
|
411
|
+
|
|
412
|
+
elif mode == 'uncertainty':
|
|
413
|
+
# UNCERTAINTY mode detected (Monte-Carlo robustness study)
|
|
414
|
+
print("\n" + "="*60)
|
|
415
|
+
print("Mode: UNCERTAINTY (Monte-Carlo robustness study)")
|
|
416
|
+
print(f"Input: {os.path.basename(input_file)}")
|
|
417
|
+
print("="*60 + "\n")
|
|
418
|
+
|
|
419
|
+
D0FUS_uncertainty.main(input_file, save_figures=True)
|
|
392
420
|
|
|
393
421
|
except ValueError as e:
|
|
394
422
|
# Invalid number of brackets or parsing error
|
|
@@ -60,6 +60,7 @@ if __name__ != "__main__":
|
|
|
60
60
|
f_CS_ACAD, f_CS_refined, f_CS_CIRCE,
|
|
61
61
|
F_CIRCE0D, compute_von_mises_stress,
|
|
62
62
|
calculate_E_mag_TF,
|
|
63
|
+
Number_TF_coils,
|
|
63
64
|
)
|
|
64
65
|
from .D0FUS_parameterization import DEFAULT_CONFIG, E_ELEM
|
|
65
66
|
|
|
@@ -90,6 +91,7 @@ else:
|
|
|
90
91
|
f_CS_ACAD, f_CS_refined, f_CS_CIRCE,
|
|
91
92
|
F_CIRCE0D, compute_von_mises_stress,
|
|
92
93
|
calculate_E_mag_TF,
|
|
94
|
+
Number_TF_coils,
|
|
93
95
|
)
|
|
94
96
|
from D0FUS_BIB.D0FUS_parameterization import DEFAULT_CONFIG, E_ELEM
|
|
95
97
|
|
|
@@ -4016,8 +4018,11 @@ def build_conductor_from_run(run: dict, coil: str = "TF") -> dict:
|
|
|
4016
4018
|
f_In = run.get(f"f_In_{coil}", np.nan)
|
|
4017
4019
|
sc_type = run.get("Supra_choice", "Nb3Sn")
|
|
4018
4020
|
|
|
4019
|
-
# Steel asymmetry parameter: n = δ_S1/δ_S2 (1 = square, 0 = optimal)
|
|
4020
|
-
|
|
4021
|
+
# Steel asymmetry parameter: n = δ_S1/δ_S2 (1 = square, 0 = optimal).
|
|
4022
|
+
# The run dict stores this as ``n_shape_TF`` / ``n_shape_CS`` (see
|
|
4023
|
+
# _build_run_dict). The previous key ``n_{coil}`` never matched, so the
|
|
4024
|
+
# aspect ratio silently stayed at 1.0 (square) for every run.
|
|
4025
|
+
n_cond = float(run.get(f"n_shape_{coil}", 1.0))
|
|
4021
4026
|
|
|
4022
4027
|
# Guard: fall back to static dict if any fraction is NaN or unphysical
|
|
4023
4028
|
fallback = _CONDUCTOR_TF if coil == "TF" else _CONDUCTOR_CS
|
|
@@ -4026,8 +4031,16 @@ def build_conductor_from_run(run: dict, coil: str = "TF") -> dict:
|
|
|
4026
4031
|
return fallback
|
|
4027
4032
|
if not all(0.0 <= f <= 1.0 for f in fracs):
|
|
4028
4033
|
return fallback
|
|
4029
|
-
|
|
4030
|
-
|
|
4034
|
+
|
|
4035
|
+
# The wost (non-steel) region is split into SIX area fractions that sum to
|
|
4036
|
+
# one: f_sc + f_cu + f_He_pipe + f_void + f_In + f_gap = 1. The run dict
|
|
4037
|
+
# only stores the first five; the sixth, f_gap (manufacturing / wrap gaps,
|
|
4038
|
+
# passed to calculate_cable_current_density but not forwarded), is recovered
|
|
4039
|
+
# here as the residual. Earlier code summed only the five stored fractions
|
|
4040
|
+
# and required the total to equal 1, so any deck with f_gap > 0 (the default
|
|
4041
|
+
# 0.15) failed the check and silently reverted to the static Nb3Sn fallback.
|
|
4042
|
+
f_gap = 1.0 - (f_sc + f_cu + f_pipe + f_void + f_In)
|
|
4043
|
+
if not (-0.05 <= f_gap <= 1.0):
|
|
4031
4044
|
return fallback
|
|
4032
4045
|
|
|
4033
4046
|
# ── Jacket aspect ratio from δ_S1/δ_S2 geometry ──
|
|
@@ -4040,15 +4053,20 @@ def build_conductor_from_run(run: dict, coil: str = "TF") -> dict:
|
|
|
4040
4053
|
# f_cable_total = wost_frac - f_insulation_total (derived)
|
|
4041
4054
|
|
|
4042
4055
|
# ── Level 2: renormalise wost fractions to cable-space ──
|
|
4043
|
-
# cable-space = wost minus insulation → fraction of wost = (1 - f_In)
|
|
4056
|
+
# cable-space = wost minus insulation → fraction of wost = (1 - f_In).
|
|
4057
|
+
# f_gap is empty (non-conducting) area, so it is merged into the He void,
|
|
4058
|
+
# which is rendered as the light-blue cable-space background. This keeps
|
|
4059
|
+
# the four Level-2 fractions summing to exactly one:
|
|
4060
|
+
# f_SC + f_Cu + f_He_pipe + f_void = 1
|
|
4044
4061
|
f_cable_wost = 1.0 - f_In
|
|
4045
4062
|
if f_cable_wost < 1e-6:
|
|
4046
4063
|
return fallback
|
|
4047
4064
|
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4065
|
+
f_void_eff = f_void + max(f_gap, 0.0)
|
|
4066
|
+
f_SC_cable = f_sc / f_cable_wost
|
|
4067
|
+
f_Cu_cable = f_cu / f_cable_wost
|
|
4068
|
+
f_He_pipe_cable = f_pipe / f_cable_wost
|
|
4069
|
+
f_void_cable = f_void_eff / f_cable_wost
|
|
4052
4070
|
|
|
4053
4071
|
# Cu_nonCu = 0: each strand is rendered as either pure SC or pure Cu.
|
|
4054
4072
|
# The correct visual ratio is already ensured by f_SC_cable / f_Cu_cable
|
|
@@ -4278,6 +4296,193 @@ def plot_CICC_cross_section(
|
|
|
4278
4296
|
# Stand-alone execution — smoke test
|
|
4279
4297
|
# =============================================================================
|
|
4280
4298
|
|
|
4299
|
+
|
|
4300
|
+
# =============================================================================
|
|
4301
|
+
# 7. TF coil number - ripple and port-access constraints
|
|
4302
|
+
# =============================================================================
|
|
4303
|
+
|
|
4304
|
+
# D0FUS-consistent palette (matches the hex codes used elsewhere in this file)
|
|
4305
|
+
_RIP_C_PLASMA = "#f4a582" # plasma fill
|
|
4306
|
+
_RIP_C_COIL = "#2166ac" # TF coil footprint
|
|
4307
|
+
_RIP_C_ACCESS = "#4dac26" # maintenance / heating access sector
|
|
4308
|
+
|
|
4309
|
+
|
|
4310
|
+
def plot_tf_ripple(
|
|
4311
|
+
R0: float = 6.2,
|
|
4312
|
+
a: float = 2.0,
|
|
4313
|
+
b: float = 1.2,
|
|
4314
|
+
ripple_adm: float = 0.01,
|
|
4315
|
+
L_min: float = 3.6,
|
|
4316
|
+
grid: int = 460,
|
|
4317
|
+
save_dir: str | None = None,
|
|
4318
|
+
) -> None:
|
|
4319
|
+
"""
|
|
4320
|
+
Top-view map of the toroidal field magnitude produced by the discrete TF
|
|
4321
|
+
coils, illustrating the field ripple.
|
|
4322
|
+
|
|
4323
|
+
The coil number, ripple and outboard standoff (N_coil, ripple, Delta_ext)
|
|
4324
|
+
are taken from the package routine ``Number_TF_coils`` so the figure stays
|
|
4325
|
+
consistent with the D0FUS sizing. Each coil is modelled as a pair of
|
|
4326
|
+
infinite straight filaments (inner leg at R0 - a - b carrying +I, outer leg
|
|
4327
|
+
at R0 + a + b + Delta_ext carrying -I, at the same toroidal angle); the field
|
|
4328
|
+
magnitude |B| is evaluated on a Cartesian grid in the toroidal mid-plane.
|
|
4329
|
+
The prefactor mu0*I/(2*pi) is dropped, so |B| is in arbitrary units. The
|
|
4330
|
+
iso-|B| contours scallop near the coils: that scalloping is the ripple.
|
|
4331
|
+
|
|
4332
|
+
Parameters
|
|
4333
|
+
----------
|
|
4334
|
+
R0, a : float Major and minor radius [m].
|
|
4335
|
+
b : float Aggregated inboard build (FW + blanket + shield + gaps) [m].
|
|
4336
|
+
ripple_adm : float Admissible ripple fraction (default 1 %).
|
|
4337
|
+
L_min : float Minimum toroidal access per coil [m] (default 3.6 m, the
|
|
4338
|
+
ITER-reproducing value; the manuscript also uses 3.0 m).
|
|
4339
|
+
grid : int Number of grid points per axis for the field map.
|
|
4340
|
+
save_dir : str or None
|
|
4341
|
+
|
|
4342
|
+
References
|
|
4343
|
+
----------
|
|
4344
|
+
Wesson, Tokamaks, 4th ed., p.169 - ripple model.
|
|
4345
|
+
Goldston & Rutherford, Introduction to Plasma Physics, IOP (1995), ch.14.
|
|
4346
|
+
"""
|
|
4347
|
+
n_coil, ripple_sel, Delta_ext = Number_TF_coils(R0, a, b, ripple_adm, L_min)
|
|
4348
|
+
R_in = R0 - a - b
|
|
4349
|
+
R_out = R0 + a + b + Delta_ext
|
|
4350
|
+
theta = 2.0 * np.pi * np.arange(n_coil) / n_coil
|
|
4351
|
+
|
|
4352
|
+
# filament positions (inner + outer legs) and signed currents
|
|
4353
|
+
fx = np.concatenate([R_in * np.cos(theta), R_out * np.cos(theta)])
|
|
4354
|
+
fy = np.concatenate([R_in * np.sin(theta), R_out * np.sin(theta)])
|
|
4355
|
+
cur = np.concatenate([np.ones(n_coil), -np.ones(n_coil)])
|
|
4356
|
+
|
|
4357
|
+
# |B| on a Cartesian grid (loop over filaments to limit memory)
|
|
4358
|
+
L = R_out + 0.7
|
|
4359
|
+
gx = np.linspace(-L, L, grid)
|
|
4360
|
+
X, Y = np.meshgrid(gx, gx)
|
|
4361
|
+
Bx = np.zeros_like(X)
|
|
4362
|
+
By = np.zeros_like(X)
|
|
4363
|
+
dmin = np.full(X.shape, np.inf)
|
|
4364
|
+
for xi, yi, ci in zip(fx, fy, cur):
|
|
4365
|
+
dx = X - xi
|
|
4366
|
+
dy = Y - yi
|
|
4367
|
+
d2 = dx * dx + dy * dy
|
|
4368
|
+
Bx += ci * (-dy) / d2
|
|
4369
|
+
By += ci * (dx) / d2
|
|
4370
|
+
dmin = np.minimum(dmin, np.sqrt(d2))
|
|
4371
|
+
rr = np.sqrt(X ** 2 + Y ** 2)
|
|
4372
|
+
Bmag = np.sqrt(Bx ** 2 + By ** 2)
|
|
4373
|
+
# cap the colour scale to the plasma-region field, then hide near-wire
|
|
4374
|
+
# spikes and the central column for legibility
|
|
4375
|
+
vmax = np.percentile(Bmag[(rr > R0 - a) & (rr < R0 + a) & (dmin > 0.22)], 99)
|
|
4376
|
+
Bmag = np.ma.array(Bmag, mask=(dmin < 0.22) | (rr < R0 - a))
|
|
4377
|
+
|
|
4378
|
+
th = np.linspace(0, 2 * np.pi, 400)
|
|
4379
|
+
fig, ax = plt.subplots(figsize=(6.4, 5.9))
|
|
4380
|
+
ax.set_aspect("equal")
|
|
4381
|
+
pcm = ax.pcolormesh(X, Y, Bmag, shading="auto", cmap="magma", vmin=0, vmax=vmax)
|
|
4382
|
+
ax.contour(X, Y, Bmag, levels=np.linspace(0.35 * vmax, vmax, 7),
|
|
4383
|
+
colors="white", linewidths=0.45, alpha=0.55)
|
|
4384
|
+
ax.add_patch(mpatches.Circle((0, 0), R0 - a, facecolor="0.75",
|
|
4385
|
+
edgecolor="0.5", lw=0.8, zorder=4))
|
|
4386
|
+
ax.text(0, 0, "central\ncolumn", ha="center", va="center", fontsize=8,
|
|
4387
|
+
color="0.25", zorder=5)
|
|
4388
|
+
ax.plot((R0 + a) * np.cos(th), (R0 + a) * np.sin(th), color="cyan",
|
|
4389
|
+
lw=1.3, ls="--", alpha=0.9, zorder=4)
|
|
4390
|
+
ax.plot(R_out * np.cos(th), R_out * np.sin(th), color="white", lw=0.7,
|
|
4391
|
+
ls=":", alpha=0.5, zorder=4)
|
|
4392
|
+
ax.plot(R_out * np.cos(theta), R_out * np.sin(theta), "o", color="white",
|
|
4393
|
+
ms=5, mec="k", mew=0.5, zorder=5)
|
|
4394
|
+
ax.text(0, (R0 + a) + 0.15, "plasma edge", color="cyan", fontsize=8,
|
|
4395
|
+
ha="center", va="bottom", zorder=6)
|
|
4396
|
+
ax.text(-L + 0.3, L - 0.5,
|
|
4397
|
+
rf"$\delta_\mathrm{{ripple}} = {ripple_sel * 100:.2f}\,\%$",
|
|
4398
|
+
color="white", fontsize=11, va="top")
|
|
4399
|
+
ax.set_xlim(-L, L)
|
|
4400
|
+
ax.set_ylim(-L, L)
|
|
4401
|
+
ax.set_xlabel("x [m]", fontsize=12)
|
|
4402
|
+
ax.set_ylabel("y [m]", fontsize=12)
|
|
4403
|
+
ax.set_title(rf"Toroidal field magnitude, top view ($N_\mathrm{{coil}} = {n_coil}$)",
|
|
4404
|
+
fontsize=11)
|
|
4405
|
+
fig.colorbar(pcm, ax=ax, shrink=0.85, label=r"$|B|$ (arb. units)")
|
|
4406
|
+
plt.tight_layout()
|
|
4407
|
+
_save_or_show(fig, save_dir, "tf_ripple")
|
|
4408
|
+
|
|
4409
|
+
|
|
4410
|
+
def plot_port_access(
|
|
4411
|
+
R0: float = 6.2,
|
|
4412
|
+
a: float = 2.0,
|
|
4413
|
+
b: float = 1.2,
|
|
4414
|
+
ripple_adm: float = 0.01,
|
|
4415
|
+
L_min: float = 3.6,
|
|
4416
|
+
save_dir: str | None = None,
|
|
4417
|
+
) -> None:
|
|
4418
|
+
"""
|
|
4419
|
+
Clean top view of the tokamak showing the TF coils and the angular sectors
|
|
4420
|
+
left free between them for maintenance and heating access.
|
|
4421
|
+
|
|
4422
|
+
The coil number and outboard standoff come from ``Number_TF_coils`` (same
|
|
4423
|
+
call as plot_tf_ripple), so the layout matches the D0FUS sizing.
|
|
4424
|
+
|
|
4425
|
+
Parameters
|
|
4426
|
+
----------
|
|
4427
|
+
R0, a, b : float Plasma geometry and aggregated inboard build [m].
|
|
4428
|
+
ripple_adm : float Admissible ripple fraction (for the N_coil selection).
|
|
4429
|
+
L_min : float Minimum toroidal pitch per coil for maintenance [m].
|
|
4430
|
+
save_dir : str or None
|
|
4431
|
+
"""
|
|
4432
|
+
n_coil, ripple_sel, Delta_ext = Number_TF_coils(R0, a, b, ripple_adm, L_min)
|
|
4433
|
+
R_out = R0 + a + b + Delta_ext
|
|
4434
|
+
theta_deg = np.degrees(2.0 * np.pi * np.arange(n_coil) / n_coil)
|
|
4435
|
+
pitch = 360.0 / n_coil
|
|
4436
|
+
coil_hw = 0.30 * pitch # illustrative coil half-width
|
|
4437
|
+
band = (R_out + 0.6) - (R0 + a)
|
|
4438
|
+
|
|
4439
|
+
fig, ax = plt.subplots(figsize=(6.2, 6.0))
|
|
4440
|
+
ax.set_aspect("equal")
|
|
4441
|
+
|
|
4442
|
+
# plasma annulus + central column
|
|
4443
|
+
ax.add_patch(mpatches.Wedge((0, 0), R0 + a, 0, 360, width=2 * a,
|
|
4444
|
+
facecolor=_RIP_C_PLASMA, alpha=0.45, lw=0, zorder=1))
|
|
4445
|
+
ax.add_patch(mpatches.Circle((0, 0), R0 - a, facecolor="0.85",
|
|
4446
|
+
edgecolor="0.6", lw=0.8, zorder=2))
|
|
4447
|
+
ax.text(0, 0, "central\ncolumn", ha="center", va="center", fontsize=8.5,
|
|
4448
|
+
color="0.3", zorder=3)
|
|
4449
|
+
ax.text(0, -R0, "plasma", ha="center", va="center", fontsize=9,
|
|
4450
|
+
color="#9c4a2f", zorder=3,
|
|
4451
|
+
bbox=dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.7))
|
|
4452
|
+
|
|
4453
|
+
# access corridors (gaps) and coil footprints
|
|
4454
|
+
for t in theta_deg:
|
|
4455
|
+
ax.add_patch(mpatches.Wedge((0, 0), R_out + 0.6, t + coil_hw,
|
|
4456
|
+
t + pitch - coil_hw, width=band,
|
|
4457
|
+
facecolor=_RIP_C_ACCESS, alpha=0.16, lw=0,
|
|
4458
|
+
zorder=2))
|
|
4459
|
+
ax.add_patch(mpatches.Wedge((0, 0), R_out + 0.6, t - coil_hw,
|
|
4460
|
+
t + coil_hw, width=1.7,
|
|
4461
|
+
facecolor=_RIP_C_COIL, edgecolor="none",
|
|
4462
|
+
alpha=0.95, zorder=3))
|
|
4463
|
+
|
|
4464
|
+
# single neutral label on one access sector
|
|
4465
|
+
tlab = theta_deg[0] + pitch / 2
|
|
4466
|
+
r_lab = R0 + a + 0.9
|
|
4467
|
+
ax.annotate("maintenance /\nheating access",
|
|
4468
|
+
xy=(r_lab * np.cos(np.radians(tlab)), r_lab * np.sin(np.radians(tlab))),
|
|
4469
|
+
xytext=(1.12 * R_out, 1.02 * R_out), fontsize=9, color="#2f6b16",
|
|
4470
|
+
ha="left", arrowprops=dict(arrowstyle="->", color="#2f6b16", lw=1.0))
|
|
4471
|
+
|
|
4472
|
+
ax.plot([], [], color=_RIP_C_COIL, lw=6, label=rf"TF coils ($N = {n_coil}$)")
|
|
4473
|
+
ax.plot([], [], color=_RIP_C_ACCESS, lw=6, alpha=0.4, label="access sectors")
|
|
4474
|
+
lim = R_out + 1.6
|
|
4475
|
+
ax.set_xlim(-lim, lim)
|
|
4476
|
+
ax.set_ylim(-lim, lim)
|
|
4477
|
+
ax.set_xlabel("x [m]", fontsize=12)
|
|
4478
|
+
ax.set_ylabel("y [m]", fontsize=12)
|
|
4479
|
+
ax.set_title(rf"TF coils and maintenance access, top view ($N_\mathrm{{coil}} = {n_coil}$)",
|
|
4480
|
+
fontsize=11)
|
|
4481
|
+
ax.legend(fontsize=9, loc="upper left")
|
|
4482
|
+
plt.tight_layout()
|
|
4483
|
+
_save_or_show(fig, save_dir, "port_access")
|
|
4484
|
+
|
|
4485
|
+
|
|
4281
4486
|
if __name__ == "__main__":
|
|
4282
4487
|
|
|
4283
4488
|
parser = argparse.ArgumentParser(
|
|
@@ -4339,4 +4544,304 @@ if __name__ == "__main__":
|
|
|
4339
4544
|
"e_shield": 0.50,
|
|
4340
4545
|
}
|
|
4341
4546
|
|
|
4342
|
-
plot_all(ITER_RUN, save_dir=_out)
|
|
4547
|
+
plot_all(ITER_RUN, save_dir=_out)
|
|
4548
|
+
# =============================================================================
|
|
4549
|
+
# UNCERTAINTY-MODE FIGURES
|
|
4550
|
+
# Appended to support the D0FUS UNCERTAINTY (Monte-Carlo) execution mode.
|
|
4551
|
+
# =============================================================================
|
|
4552
|
+
"""
|
|
4553
|
+
D0FUS_uncertainty_figures.py -- decision-oriented plots for the uncertainty study.
|
|
4554
|
+
|
|
4555
|
+
Figures, in the D0FUS figure style (matplotlib, tab: palette, 150 dpi, tight box).
|
|
4556
|
+
Each figure carries a one-line plain-language reading note so it stands on its own:
|
|
4557
|
+
|
|
4558
|
+
- fig_robustness : single decomposition bar of the whole Monte-Carlo. Green is
|
|
4559
|
+
feasible, coloured shares are infeasible cases split by the binding limit, grey
|
|
4560
|
+
did not converge. P(feasible) is over ALL samples (a non-converging corner counts
|
|
4561
|
+
as a failure).
|
|
4562
|
+
- fig_margins : headroom to each plasma limit (P5/P50/P95 of the normalised margin).
|
|
4563
|
+
- scan_feasibility + fig_scan : one-parameter feasibility scans with a traffic-light
|
|
4564
|
+
background, so a glance places the design value in a safe / marginal / unlikely zone.
|
|
4565
|
+
- fig_models : impact of the model-form choices (confinement scaling, elongation, ...)
|
|
4566
|
+
on feasibility and on a key physics output, one row per model combination.
|
|
4567
|
+
"""
|
|
4568
|
+
import os
|
|
4569
|
+
from collections import Counter
|
|
4570
|
+
|
|
4571
|
+
import numpy as np
|
|
4572
|
+
import matplotlib
|
|
4573
|
+
import matplotlib.pyplot as plt
|
|
4574
|
+
from matplotlib.patches import Patch
|
|
4575
|
+
|
|
4576
|
+
_GREEN, _AMBER, _RED = 'tab:green', 'tab:orange', 'tab:red'
|
|
4577
|
+
_BIND_COLOR = {'greenwald': 'tab:blue', 'troyon': 'tab:red',
|
|
4578
|
+
'kink': 'tab:purple', 'build': 'tab:brown'}
|
|
4579
|
+
_BIND_LABEL = {'greenwald': 'Greenwald-limited', 'troyon': 'Troyon-limited',
|
|
4580
|
+
'kink': 'kink-limited', 'build': 'build infeasible'}
|
|
4581
|
+
# nice axis names for the scan
|
|
4582
|
+
_NICE = {'P_fus': 'fusion power $P_{fus}$', 'R0': 'major radius $R_0$',
|
|
4583
|
+
'a': 'minor radius $a$', 'Tbar': r'temperature $\langle T \rangle$'}
|
|
4584
|
+
_UNIT = {'P_fus': '[MW]', 'R0': '[m]', 'a': '[m]', 'Tbar': '[keV]'}
|
|
4585
|
+
|
|
4586
|
+
|
|
4587
|
+
def _save(fig, save_dir, fname):
|
|
4588
|
+
if save_dir:
|
|
4589
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
4590
|
+
fig.savefig(os.path.join(save_dir, f"{fname}.png"), dpi=150, bbox_inches='tight')
|
|
4591
|
+
return fig
|
|
4592
|
+
|
|
4593
|
+
|
|
4594
|
+
def _zone_color(p):
|
|
4595
|
+
return _GREEN if p >= 85 else (_AMBER if p >= 60 else _RED)
|
|
4596
|
+
|
|
4597
|
+
|
|
4598
|
+
# =============================================================================
|
|
4599
|
+
# Figure 1 -- robustness verdict (single decomposition bar)
|
|
4600
|
+
# =============================================================================
|
|
4601
|
+
def fig_robustness(results, save_dir=None):
|
|
4602
|
+
"""Decompose the Monte-Carlo into feasible / per-limit infeasible / non-converged."""
|
|
4603
|
+
all_rows = [r for k in results for r in results[k]]
|
|
4604
|
+
n = max(len(all_rows), 1)
|
|
4605
|
+
conv = [r for r in all_rows if r.get('converged')]
|
|
4606
|
+
feas = [r for r in conv if r.get('feasible')]
|
|
4607
|
+
n_noconv = len(all_rows) - len(conv)
|
|
4608
|
+
binding = Counter(r.get('binding') for r in conv if not r.get('feasible'))
|
|
4609
|
+
|
|
4610
|
+
seg = [('feasible', len(feas), _GREEN)]
|
|
4611
|
+
for c in ['greenwald', 'troyon', 'kink', 'build']:
|
|
4612
|
+
if binding.get(c, 0):
|
|
4613
|
+
seg.append((_BIND_LABEL[c], binding[c], _BIND_COLOR[c]))
|
|
4614
|
+
if n_noconv:
|
|
4615
|
+
seg.append(('did not converge', n_noconv, 'lightgray'))
|
|
4616
|
+
|
|
4617
|
+
fig, ax = plt.subplots(figsize=(11, 3.4))
|
|
4618
|
+
left = 0.0
|
|
4619
|
+
for label, count, col in seg:
|
|
4620
|
+
w = 100.0 * count / n
|
|
4621
|
+
ax.barh(0, w, left=left, color=col, edgecolor='white', height=0.5)
|
|
4622
|
+
if w >= 4:
|
|
4623
|
+
ax.text(left + w / 2, 0, f'{w:.0f}%', ha='center', va='center',
|
|
4624
|
+
fontsize=11, fontweight='bold',
|
|
4625
|
+
color='white' if col != 'lightgray' else 'black')
|
|
4626
|
+
left += w
|
|
4627
|
+
|
|
4628
|
+
p_feas = 100.0 * len(feas) / n
|
|
4629
|
+
verdict = ('LARGELY FEASIBLE' if p_feas >= 85 else
|
|
4630
|
+
'MARGINAL' if p_feas >= 60 else 'AT RISK')
|
|
4631
|
+
ax.set_xlim(0, 100)
|
|
4632
|
+
ax.set_ylim(-0.5, 0.55)
|
|
4633
|
+
ax.set_yticks([])
|
|
4634
|
+
ax.set_xlabel('share of Monte-Carlo samples [%]', fontsize=12)
|
|
4635
|
+
ax.set_title(f'Design robustness: {p_feas:.0f}% feasible over N = {n} samples'
|
|
4636
|
+
f' -- verdict: {verdict}', fontsize=13, fontweight='bold')
|
|
4637
|
+
|
|
4638
|
+
# plain-language reading note
|
|
4639
|
+
if binding:
|
|
4640
|
+
top = max(binding, key=binding.get)
|
|
4641
|
+
cause = _BIND_LABEL.get(top, top).replace('-limited', ' limit').replace(' infeasible', '')
|
|
4642
|
+
note = f"about {p_feas:.0f} designs out of 100 stay within every limit; " \
|
|
4643
|
+
f"the rest are mostly held back by the {cause}"
|
|
4644
|
+
else:
|
|
4645
|
+
note = f"about {p_feas:.0f} designs out of 100 stay within every limit"
|
|
4646
|
+
if n_noconv:
|
|
4647
|
+
note += "; grey = solver did not converge at extreme corners"
|
|
4648
|
+
ax.annotate(note, xy=(0.5, -0.55), xycoords='axes fraction', ha='center',
|
|
4649
|
+
fontsize=10, color='dimgray')
|
|
4650
|
+
|
|
4651
|
+
ax.legend(handles=[Patch(color=c, label=l) for l, _, c in seg],
|
|
4652
|
+
fontsize=9, ncol=len(seg), loc='upper center',
|
|
4653
|
+
bbox_to_anchor=(0.5, -0.55))
|
|
4654
|
+
plt.tight_layout()
|
|
4655
|
+
return _save(fig, save_dir, 'uq_robustness')
|
|
4656
|
+
|
|
4657
|
+
|
|
4658
|
+
# =============================================================================
|
|
4659
|
+
# Figure 2 -- headroom to each limit (margin spread)
|
|
4660
|
+
# =============================================================================
|
|
4661
|
+
def fig_margins(results, save_dir=None):
|
|
4662
|
+
"""P5/P50/P95 of the normalised margin to each continuous-margin limit."""
|
|
4663
|
+
rows = [r for k in results for r in results[k] if r.get('converged')]
|
|
4664
|
+
margins = {
|
|
4665
|
+
'Greenwald': [r.get('gw_margin', np.nan) for r in rows],
|
|
4666
|
+
'Troyon': [r.get('troyon_margin', np.nan) for r in rows],
|
|
4667
|
+
'Kink (q95)': [r.get('kink_margin', np.nan) for r in rows],
|
|
4668
|
+
}
|
|
4669
|
+
ynames = list(margins)
|
|
4670
|
+
y = np.arange(len(ynames))
|
|
4671
|
+
|
|
4672
|
+
fig, ax = plt.subplots(figsize=(9.5, 4.2))
|
|
4673
|
+
# safe side shading (everything right of the limit)
|
|
4674
|
+
ax.axvspan(0, 1.0, color=_GREEN, alpha=0.05)
|
|
4675
|
+
for yi, name in zip(y, ynames):
|
|
4676
|
+
a = np.array([v for v in margins[name] if np.isfinite(v)])
|
|
4677
|
+
if a.size == 0:
|
|
4678
|
+
continue
|
|
4679
|
+
p5, p50, p95 = np.percentile(a, [5, 50, 95])
|
|
4680
|
+
col = _RED if p5 < 0 else (_AMBER if p5 < 0.05 else _GREEN)
|
|
4681
|
+
ax.plot([p5, p95], [yi, yi], color=col, lw=8, alpha=0.45, solid_capstyle='round')
|
|
4682
|
+
ax.plot(p50, yi, 'o', color=col, ms=11)
|
|
4683
|
+
ax.text(p95 + 0.015, yi, f'P50={p50:+.2f}', va='center', fontsize=9.5)
|
|
4684
|
+
ax.axvline(0, color='k', lw=1.4)
|
|
4685
|
+
ax.annotate('at the limit', xy=(0, len(ynames) - 0.45), fontsize=9,
|
|
4686
|
+
color='k', ha='center', va='bottom')
|
|
4687
|
+
ax.set_yticks(y)
|
|
4688
|
+
ax.set_yticklabels(ynames, fontsize=12)
|
|
4689
|
+
ax.set_ylim(-0.7, len(ynames) - 0.2)
|
|
4690
|
+
ax.set_xlabel('headroom to the limit (0 = at the limit, further right = safer)',
|
|
4691
|
+
fontsize=11)
|
|
4692
|
+
ax.set_title('Headroom to each plasma limit under uncertainty',
|
|
4693
|
+
fontsize=13, fontweight='bold')
|
|
4694
|
+
ax.legend(handles=[Patch(color=_GREEN, label='comfortable margin'),
|
|
4695
|
+
Patch(color=_AMBER, label='tight margin'),
|
|
4696
|
+
Patch(color=_RED, label='margin can reach the limit')],
|
|
4697
|
+
fontsize=9, ncol=3, loc='upper center', bbox_to_anchor=(0.5, -0.26))
|
|
4698
|
+
ax.annotate('bar = P5 to P95 over the Monte-Carlo; touching the line on the left '
|
|
4699
|
+
'means that limit can be crossed',
|
|
4700
|
+
xy=(0.5, -0.44), xycoords='axes fraction', ha='center',
|
|
4701
|
+
fontsize=9.5, color='dimgray')
|
|
4702
|
+
plt.tight_layout()
|
|
4703
|
+
return _save(fig, save_dir, 'uq_margins')
|
|
4704
|
+
|
|
4705
|
+
|
|
4706
|
+
# =============================================================================
|
|
4707
|
+
# Figure 3 -- one-parameter feasibility scan (Monte-Carlo at each value)
|
|
4708
|
+
# =============================================================================
|
|
4709
|
+
def scan_feasibility(uq_file, scan_specs, n_samples=200, n_jobs=-1, combo=None, seed=0, verbose=10):
|
|
4710
|
+
"""
|
|
4711
|
+
Sweep each design parameter and run a Monte-Carlo over all the other uncertain
|
|
4712
|
+
inputs at every value. A common LHS sample is reused across a parameter's scan
|
|
4713
|
+
points (common random numbers) so the curve is smooth. The whole scan is evaluated
|
|
4714
|
+
in ONE parallel pass over all (parameter, point, sample) tasks, which avoids the
|
|
4715
|
+
overhead of opening a separate worker pool at every scan point.
|
|
4716
|
+
|
|
4717
|
+
scan_specs : {param: (lo, hi, n_points)}
|
|
4718
|
+
Returns : {param: (x_values, P_feasible[%], design_value)}
|
|
4719
|
+
"""
|
|
4720
|
+
from joblib import Parallel, delayed
|
|
4721
|
+
from D0FUS_EXE import D0FUS_uncertainty as UQ
|
|
4722
|
+
|
|
4723
|
+
base, spec, envelope, controls, deck_path = UQ.parse_uq_file(uq_file)
|
|
4724
|
+
combo = combo or {}
|
|
4725
|
+
|
|
4726
|
+
grids, tasks = {}, []
|
|
4727
|
+
for p, (lo, hi, npts) in scan_specs.items():
|
|
4728
|
+
reduced = {k: v for k, v in spec.items() if k != p} # exclude the scanned input
|
|
4729
|
+
names = list(reduced.keys())
|
|
4730
|
+
_, X = UQ.sample_lhs(reduced, n_samples, seed=seed)
|
|
4731
|
+
xs = np.unique(np.concatenate([np.linspace(lo, hi, npts),
|
|
4732
|
+
[float(getattr(base, p))]])) # design value on grid
|
|
4733
|
+
grids[p] = (xs, float(getattr(base, p)))
|
|
4734
|
+
for x in xs:
|
|
4735
|
+
for i in range(n_samples):
|
|
4736
|
+
tasks.append((deck_path, names, X[i], {**combo, p: x}))
|
|
4737
|
+
|
|
4738
|
+
# Single tqdm bar in place of joblib's per-batch log lines. return_as
|
|
4739
|
+
# 'generator' keeps submission order so the per-parameter slicing below holds.
|
|
4740
|
+
_gen = Parallel(n_jobs=n_jobs, return_as="generator")(delayed(UQ._uq_worker)(*t) for t in tasks)
|
|
4741
|
+
rows = list(tqdm(_gen, total=len(tasks), desc="Feasibility scan",
|
|
4742
|
+
unit="run", disable=(verbose == 0)))
|
|
4743
|
+
|
|
4744
|
+
out, idx = {}, 0
|
|
4745
|
+
for p, (xs, nom) in grids.items():
|
|
4746
|
+
pf = []
|
|
4747
|
+
for _ in xs:
|
|
4748
|
+
chunk = rows[idx:idx + n_samples]
|
|
4749
|
+
idx += n_samples
|
|
4750
|
+
feas = [r for r in chunk if r.get('converged') and r.get('feasible')]
|
|
4751
|
+
pf.append(100.0 * len(feas) / n_samples) # over all samples
|
|
4752
|
+
out[p] = (xs, np.array(pf), nom)
|
|
4753
|
+
return out
|
|
4754
|
+
|
|
4755
|
+
|
|
4756
|
+
def fig_scan(scan_results, save_dir=None):
|
|
4757
|
+
"""Four-panel feasibility scan with a traffic-light background; a vertical band
|
|
4758
|
+
marks the design value of each parameter."""
|
|
4759
|
+
params = list(scan_results)
|
|
4760
|
+
ncols = 2
|
|
4761
|
+
nrows = int(np.ceil(len(params) / ncols))
|
|
4762
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(5.7 * ncols, 3.9 * nrows))
|
|
4763
|
+
axes = np.atleast_1d(axes).ravel()
|
|
4764
|
+
|
|
4765
|
+
for ax, p in zip(axes, params):
|
|
4766
|
+
xs, pf, nom = scan_results[p]
|
|
4767
|
+
# traffic-light zones
|
|
4768
|
+
ax.axhspan(85, 105, color=_GREEN, alpha=0.10)
|
|
4769
|
+
ax.axhspan(60, 85, color=_AMBER, alpha=0.10)
|
|
4770
|
+
ax.axhspan(0, 60, color=_RED, alpha=0.10)
|
|
4771
|
+
ax.plot(xs, pf, '-o', color='black', lw=2, ms=3, zorder=4)
|
|
4772
|
+
# vertical band marking the design value
|
|
4773
|
+
ax.axvline(nom, color='0.15', ls='--', lw=1.6, zorder=5)
|
|
4774
|
+
ax.text(nom, 50, ' design value ', rotation=90, va='center', ha='center',
|
|
4775
|
+
fontsize=8.5, color='0.15', zorder=6,
|
|
4776
|
+
bbox=dict(boxstyle='round,pad=0.15', fc='white', ec='0.6', alpha=0.85))
|
|
4777
|
+
ax.set_xlabel(f"{_NICE.get(p, p)} {_UNIT.get(p, '')}", fontsize=11)
|
|
4778
|
+
ax.set_ylabel('chance of staying feasible [%]', fontsize=10)
|
|
4779
|
+
ax.set_ylim(0, 105)
|
|
4780
|
+
ax.set_xlim(xs.min(), xs.max())
|
|
4781
|
+
|
|
4782
|
+
for ax in axes[len(params):]:
|
|
4783
|
+
ax.axis('off')
|
|
4784
|
+
|
|
4785
|
+
plt.suptitle('How feasibility responds to each design choice',
|
|
4786
|
+
fontsize=14, fontweight='bold')
|
|
4787
|
+
fig.legend(handles=[Patch(color=_GREEN, alpha=0.35, label='safe (>= 85%)'),
|
|
4788
|
+
Patch(color=_AMBER, alpha=0.35, label='marginal (60-85%)'),
|
|
4789
|
+
Patch(color=_RED, alpha=0.35, label='unlikely (< 60%)')],
|
|
4790
|
+
loc='lower center', ncol=3, fontsize=9, frameon=False,
|
|
4791
|
+
bbox_to_anchor=(0.5, -0.02))
|
|
4792
|
+
plt.tight_layout(rect=[0, 0.04, 1, 0.96])
|
|
4793
|
+
return _save(fig, save_dir, 'uq_scan_feasibility')
|
|
4794
|
+
|
|
4795
|
+
|
|
4796
|
+
# =============================================================================
|
|
4797
|
+
# Figure 4 -- impact of the physics models (envelope combinations)
|
|
4798
|
+
# =============================================================================
|
|
4799
|
+
def fig_models(results, qoi='Q', save_dir=None):
|
|
4800
|
+
"""
|
|
4801
|
+
How the model-form choices (confinement scaling law, elongation model, ...) move
|
|
4802
|
+
feasibility. One row per model combination: a wide spread between rows means the
|
|
4803
|
+
model assumptions matter, a tight spread means they do not. (qoi kept for backward
|
|
4804
|
+
compatibility; no longer plotted.)
|
|
4805
|
+
"""
|
|
4806
|
+
combos = [k for k in results if k != ('nominal',)]
|
|
4807
|
+
if not combos:
|
|
4808
|
+
return None # no model envelope in this study
|
|
4809
|
+
|
|
4810
|
+
def _label(key):
|
|
4811
|
+
d = dict(key)
|
|
4812
|
+
order = ['Scaling_Law', 'Option_Kappa', 'Bootstrap_choice']
|
|
4813
|
+
vals = [str(d[k]) for k in order if k in d]
|
|
4814
|
+
vals += [str(v) for k, v in key if k not in order]
|
|
4815
|
+
return ' · '.join(vals)
|
|
4816
|
+
|
|
4817
|
+
labels, p_feas = [], []
|
|
4818
|
+
for k in combos:
|
|
4819
|
+
rws = results[k]
|
|
4820
|
+
n = max(len(rws), 1)
|
|
4821
|
+
feas = [r for r in rws if r.get('converged') and r.get('feasible')]
|
|
4822
|
+
labels.append(_label(k))
|
|
4823
|
+
p_feas.append(100.0 * len(feas) / n)
|
|
4824
|
+
|
|
4825
|
+
order = np.argsort(p_feas) # worst feasibility at the bottom
|
|
4826
|
+
labels = [labels[i] for i in order]
|
|
4827
|
+
p_feas = [p_feas[i] for i in order]
|
|
4828
|
+
y = np.arange(len(labels))
|
|
4829
|
+
|
|
4830
|
+
fig, ax = plt.subplots(figsize=(10, 0.7 * len(labels) + 2.2))
|
|
4831
|
+
grays = [str(max(0.25, 0.85 - 0.6 * p / 100.0)) for p in p_feas] # darker = higher
|
|
4832
|
+
ax.barh(y, p_feas, color=grays, edgecolor='black', lw=0.8)
|
|
4833
|
+
for yi, p in zip(y, p_feas):
|
|
4834
|
+
ax.text(min(p + 1.5, 97), yi, f'{p:.0f}%', va='center', fontsize=10)
|
|
4835
|
+
ax.axvline(85, color='0.3', ls=':', lw=1)
|
|
4836
|
+
ax.set_xlim(0, 105)
|
|
4837
|
+
ax.set_yticks(y)
|
|
4838
|
+
ax.set_yticklabels(labels, fontsize=11)
|
|
4839
|
+
ax.set_xlabel('probability of staying feasible [%]', fontsize=11)
|
|
4840
|
+
ax.set_title('Impact of the physics models on feasibility',
|
|
4841
|
+
fontsize=13, fontweight='bold')
|
|
4842
|
+
ax.annotate('each row is one model combination (confinement scaling · elongation); '
|
|
4843
|
+
'the spread shows how much the model assumptions drive feasibility',
|
|
4844
|
+
xy=(0.5, -0.30 if len(labels) <= 4 else -0.18), xycoords='axes fraction',
|
|
4845
|
+
ha='center', fontsize=9.5, color='dimgray')
|
|
4846
|
+
plt.tight_layout()
|
|
4847
|
+
return _save(fig, save_dir, 'uq_models')
|