d0fus 2.3.2__tar.gz → 2.3.4__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.
@@ -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, D0FUS_popcon
24
24
 
25
25
  #%% Mode detection
26
26
 
@@ -41,6 +41,20 @@ 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
+ # POPCON mode: a [POPCON] section. Checked first so that the grid triples
45
+ # nbar_line/Tbar = [min, max, n] do not make the file look like a SCAN.
46
+ if re.search(r'^\s*\[\s*popcon\s*\]', content, re.MULTILINE | re.IGNORECASE):
47
+ return 'popcon', None
48
+
49
+ # UNCERTAINTY mode: an [UNCERTAINTY] section, or any tri()/norm()/unif()/envelope()
50
+ # marginal. Checked first so that optional map axes (a = [min, max, n]) do not make
51
+ # the file look like a SCAN.
52
+ if (re.search(r'^\s*\[\s*uncertainty\s*\]', content, re.MULTILINE | re.IGNORECASE)
53
+ or re.search(r'=\s*(tri|norm|unif|envelope)\s*\(', content, re.IGNORECASE)):
54
+ n_uncertain = len(re.findall(r'^\s*\w+\s*=\s*(?:tri|norm|unif|envelope)\s*\(',
55
+ content, re.MULTILINE | re.IGNORECASE))
56
+ return 'uncertainty', n_uncertain
57
+
44
58
  # Extract genetic algorithm parameters if present
45
59
  genetic_params = {}
46
60
  genetic_keywords = {
@@ -239,11 +253,18 @@ Modes (detected automatically from input file format):
239
253
  crossover_rate = 0.7 (default: 0.7)
240
254
  mutation_rate = 0.2 (default: 0.2)
241
255
 
256
+ UNCERTAINTY mode: Monte-Carlo robustness study around one design point
257
+ An [UNCERTAINTY] section listing parameter ranges
258
+ Example: H = tri(0.75, 1.5)
259
+ betaN_limit = tri(2.8, 3.6)
260
+ Scaling_Law = envelope(IPB98(y,2) | ITPA20)
261
+
242
262
  Detection rules:
243
263
  • [min, max] format (2 values) → OPTIMIZATION (need 2+ parameters)
244
264
  • [min, max, n] format (3 values) → SCAN (need exactly 2 parameters)
245
- No bracketsRUN
246
- Cannot mix formats in same file
265
+ [UNCERTAINTY] section or tri()/norm()/unif()/envelope() UNCERTAINTY
266
+ No brackets (and no [UNCERTAINTY] section) → RUN
267
+ • Cannot mix scan and optimization formats in same file
247
268
 
248
269
  For help:
249
270
  python D0FUS.py --help
@@ -283,6 +304,9 @@ def select_input_file():
283
304
  opt_params, genetic_params = params
284
305
  param_names = list(opt_params.keys())
285
306
  mode_str = f"GENETIC ({len(param_names)} params)"
307
+ elif mode == 'uncertainty':
308
+ mode_str = (f"UNCERTAINTY ({params} params)" if params
309
+ else "UNCERTAINTY (Monte-Carlo)")
286
310
  else:
287
311
  mode_str = "RUN"
288
312
  print(f" {i}. {file.name:<30} [{mode_str}]")
@@ -335,6 +359,12 @@ def execute_with_mode_detection(input_file):
335
359
  print("="*60 + "\n")
336
360
  D0FUS_run.main(input_file, save_figures=True)
337
361
 
362
+ elif mode == 'popcon':
363
+ print("\n" + "="*60)
364
+ print("Mode: POPCON (operating-space contour map)")
365
+ print(f"Input: {os.path.basename(input_file)}")
366
+ print("="*60 + "\n")
367
+ D0FUS_popcon.main(input_file, save_figures=True)
338
368
  elif mode == 'scan':
339
369
  # SCAN mode detected
340
370
  param_names = [p[0] for p in params]
@@ -389,6 +419,15 @@ def execute_with_mode_detection(input_file):
389
419
 
390
420
  # Run genetic optimization with specified or default parameters
391
421
  D0FUS_genetic.run_genetic_optimization(input_file, **ga_params)
422
+
423
+ elif mode == 'uncertainty':
424
+ # UNCERTAINTY mode detected (Monte-Carlo robustness study)
425
+ print("\n" + "="*60)
426
+ print("Mode: UNCERTAINTY (Monte-Carlo robustness study)")
427
+ print(f"Input: {os.path.basename(input_file)}")
428
+ print("="*60 + "\n")
429
+
430
+ D0FUS_uncertainty.main(input_file, save_figures=True)
392
431
 
393
432
  except ValueError as e:
394
433
  # Invalid number of brackets or parsing error
@@ -4018,8 +4018,11 @@ def build_conductor_from_run(run: dict, coil: str = "TF") -> dict:
4018
4018
  f_In = run.get(f"f_In_{coil}", np.nan)
4019
4019
  sc_type = run.get("Supra_choice", "Nb3Sn")
4020
4020
 
4021
- # Steel asymmetry parameter: n = δ_S1/δ_S2 (1 = square, 0 = optimal)
4022
- n_cond = float(run.get(f"n_{coil}", 1.0))
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))
4023
4026
 
4024
4027
  # Guard: fall back to static dict if any fraction is NaN or unphysical
4025
4028
  fallback = _CONDUCTOR_TF if coil == "TF" else _CONDUCTOR_CS
@@ -4028,8 +4031,16 @@ def build_conductor_from_run(run: dict, coil: str = "TF") -> dict:
4028
4031
  return fallback
4029
4032
  if not all(0.0 <= f <= 1.0 for f in fracs):
4030
4033
  return fallback
4031
- wost_sum = f_sc + f_cu + f_pipe + f_void + f_In
4032
- if abs(wost_sum - 1.0) > 0.05:
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):
4033
4044
  return fallback
4034
4045
 
4035
4046
  # ── Jacket aspect ratio from δ_S1/δ_S2 geometry ──
@@ -4042,15 +4053,20 @@ def build_conductor_from_run(run: dict, coil: str = "TF") -> dict:
4042
4053
  # f_cable_total = wost_frac - f_insulation_total (derived)
4043
4054
 
4044
4055
  # ── Level 2: renormalise wost fractions to cable-space ──
4045
- # 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
4046
4061
  f_cable_wost = 1.0 - f_In
4047
4062
  if f_cable_wost < 1e-6:
4048
4063
  return fallback
4049
4064
 
4050
- f_SC_cable = f_sc / f_cable_wost
4051
- f_Cu_cable = f_cu / f_cable_wost
4052
- f_He_pipe_cable = f_pipe / f_cable_wost
4053
- f_void_cable = f_void / f_cable_wost
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
4054
4070
 
4055
4071
  # Cu_nonCu = 0: each strand is rendered as either pure SC or pure Cu.
4056
4072
  # The correct visual ratio is already ensured by f_SC_cable / f_Cu_cable
@@ -4467,19 +4483,8 @@ def plot_port_access(
4467
4483
  _save_or_show(fig, save_dir, "port_access")
4468
4484
 
4469
4485
 
4470
- if __name__ == "__main__":
4471
-
4472
- parser = argparse.ArgumentParser(
4473
- description="D0FUS_figures.py — stand-alone smoke test.\n"
4474
- "Renders all figures interactively by default.\n"
4475
- "Pass --save-dir to write PNG files instead.")
4476
- parser.add_argument("--save-dir", default=None,
4477
- help="Directory to save PNG figures (suppresses interactive display)")
4478
- args = parser.parse_args()
4479
-
4480
- _out = args.save_dir
4481
- if _out is not None:
4482
- os.makedirs(_out, exist_ok=True)
4486
+ def demo_run_dict():
4487
+ """ITER Q=10 reference run-dict used by the stand-alone figure demo."""
4483
4488
 
4484
4489
  # ITER Q=10 reference case — Shimada et al., Nucl. Fusion 47 (2007) S1
4485
4490
  ITER_RUN = {
@@ -4528,4 +4533,492 @@ if __name__ == "__main__":
4528
4533
  "e_shield": 0.50,
4529
4534
  }
4530
4535
 
4531
- plot_all(ITER_RUN, save_dir=_out)
4536
+ return ITER_RUN
4537
+
4538
+
4539
+ # =============================================================================
4540
+ # FIGURE REGISTRY & STAND-ALONE CATALOGUE
4541
+ # =============================================================================
4542
+
4543
+ def build_figure_registry():
4544
+ """
4545
+ Auto-populated catalogue of every figure function in this module.
4546
+
4547
+ A figure function is any module-level callable named plot_* or fig_*.
4548
+ The one-line description is the first line of its docstring. The
4549
+ category is inferred from the first positional argument, following the
4550
+ module conventions:
4551
+ run dict -> 'run' (rendered by plot_all on a run dict)
4552
+ results -> 'uncertainty' (Monte-Carlo robustness figures)
4553
+ names -> 'uncertainty' (Sobol indices figure)
4554
+ scan_results -> 'scan'
4555
+ anything else -> 'standalone' (parametric / validation figures
4556
+ taking explicit physics arguments)
4557
+
4558
+ Returns
4559
+ -------
4560
+ dict : name -> dict(func, description, category, signature)
4561
+ """
4562
+ import inspect as _inspect
4563
+ registry = {}
4564
+ for name, obj in sorted(globals().items()):
4565
+ if not callable(obj) or not (name.startswith('plot_')
4566
+ or name.startswith('fig_')):
4567
+ continue
4568
+ try:
4569
+ sig = _inspect.signature(obj)
4570
+ params = list(sig.parameters)
4571
+ except (TypeError, ValueError):
4572
+ sig, params = None, []
4573
+ doc = (_inspect.getdoc(obj) or '').strip().split('\n')
4574
+ desc = doc[0] if doc and doc[0] else '(no description)'
4575
+ first = params[0] if params else ''
4576
+ if name == 'plot_all':
4577
+ cat = 'meta'
4578
+ elif first == 'run':
4579
+ cat = 'run'
4580
+ elif first in ('results', 'names'):
4581
+ cat = 'uncertainty'
4582
+ elif first == 'scan_results' or 'scan' in name:
4583
+ cat = 'scan'
4584
+ else:
4585
+ cat = 'standalone'
4586
+ registry[name] = dict(func=obj, description=desc, category=cat,
4587
+ signature=str(sig) if sig else '(?)')
4588
+ return registry
4589
+
4590
+
4591
+ _CATEGORY_ORDER = ('meta', 'run', 'standalone', 'scan', 'uncertainty')
4592
+ _CATEGORY_LABEL = {
4593
+ 'meta': 'META — full catalogue renderer',
4594
+ 'run': 'RUN-DICT FIGURES — rendered from a D0FUS run dict',
4595
+ 'standalone': 'STANDALONE FIGURES — parametric / validation '
4596
+ '(explicit physics arguments)',
4597
+ 'scan': 'SCAN FIGURES — rendered from scan-mode outputs',
4598
+ 'uncertainty': 'UNCERTAINTY FIGURES — rendered from UQ / Sobol outputs',
4599
+ }
4600
+
4601
+
4602
+ def print_figure_catalog(registry=None):
4603
+ """Print the grouped figure catalogue (name + one-line description)."""
4604
+ registry = registry or build_figure_registry()
4605
+ width = max(len(n) for n in registry)
4606
+ print("=" * 78)
4607
+ print(f"D0FUS figure catalogue — {len(registry)} functions")
4608
+ print("=" * 78)
4609
+ for cat in _CATEGORY_ORDER:
4610
+ entries = {n: e for n, e in registry.items() if e['category'] == cat}
4611
+ if not entries:
4612
+ continue
4613
+ print(f"\n── {_CATEGORY_LABEL[cat]}")
4614
+ for name, e in entries.items():
4615
+ print(f" {name:<{width}} {e['description']}")
4616
+ print("\nUsage: --list (catalogue only) | --only name1,name2 | --save-dir DIR")
4617
+ print("=" * 78)
4618
+
4619
+
4620
+ def _standalone_main():
4621
+ parser = argparse.ArgumentParser(
4622
+ description="D0FUS_figures.py — stand-alone figure catalogue.\n"
4623
+ "Prints the catalogue, then renders the full demo "
4624
+ "sequence (plot_all on the ITER reference run dict) "
4625
+ "interactively by default.")
4626
+ parser.add_argument("--save-dir", default=None,
4627
+ help="Directory to save PNG figures "
4628
+ "(suppresses interactive display)")
4629
+ parser.add_argument("--list", action="store_true",
4630
+ help="Print the figure catalogue and exit")
4631
+ parser.add_argument("--only", default=None,
4632
+ help="Comma-separated figure names to render "
4633
+ "(run-dict figures use the ITER demo dict; "
4634
+ "standalone figures need all-default arguments)")
4635
+ args = parser.parse_args()
4636
+
4637
+ registry = build_figure_registry()
4638
+ print_figure_catalog(registry)
4639
+ if args.list:
4640
+ return
4641
+
4642
+ _out = args.save_dir
4643
+ if _out is not None:
4644
+ os.makedirs(_out, exist_ok=True)
4645
+
4646
+ if args.only:
4647
+ wanted = [w.strip() for w in args.only.split(',') if w.strip()]
4648
+ run = demo_run_dict()
4649
+ for name in wanted:
4650
+ if name not in registry:
4651
+ print(f" [skip] unknown figure '{name}' (see catalogue above)")
4652
+ continue
4653
+ e = registry[name]
4654
+ print(f" rendering {name} ...")
4655
+ try:
4656
+ if e['category'] in ('run', 'meta'):
4657
+ e['func'](run, save_dir=_out)
4658
+ else:
4659
+ # Standalone figures: only callable here when every
4660
+ # physics argument has a default; pass save_dir when the
4661
+ # signature accepts it, otherwise call bare.
4662
+ try:
4663
+ e['func'](save_dir=_out)
4664
+ except TypeError:
4665
+ e['func']()
4666
+ except TypeError:
4667
+ print(f" [skip] {name} requires explicit arguments: "
4668
+ f"{name}{e['signature']} — call it from Python or "
4669
+ f"through its execution mode.")
4670
+ return
4671
+
4672
+ plot_all(demo_run_dict(), save_dir=_out)
4673
+
4674
+ # =============================================================================
4675
+ # UNCERTAINTY-MODE FIGURES
4676
+ # Appended to support the D0FUS UNCERTAINTY (Monte-Carlo) execution mode.
4677
+ # =============================================================================
4678
+ """
4679
+ D0FUS_uncertainty_figures.py -- decision-oriented plots for the uncertainty study.
4680
+
4681
+ Figures, in the D0FUS figure style (matplotlib, tab: palette, 150 dpi, tight box).
4682
+ Each figure carries a one-line plain-language reading note so it stands on its own:
4683
+
4684
+ - fig_robustness : single decomposition bar of the whole Monte-Carlo. Green is
4685
+ feasible, coloured shares are infeasible cases split by the binding limit, grey
4686
+ did not converge. P(feasible) is over ALL samples (a non-converging corner counts
4687
+ as a failure).
4688
+ - fig_margins : headroom to each plasma limit (P5/P50/P95 of the normalised margin).
4689
+ - scan_feasibility + fig_scan : one-parameter feasibility scans with a traffic-light
4690
+ background, so a glance places the design value in a safe / marginal / unlikely zone.
4691
+ - fig_models : impact of the model-form choices (confinement scaling, elongation, ...)
4692
+ on feasibility and on a key physics output, one row per model combination.
4693
+ """
4694
+ def fig_sobol(names, sobol, meta, save_dir=None, show=False):
4695
+ """
4696
+ Sobol sensitivity bar charts: one panel per analysed QoI, horizontal bars
4697
+ for the first-order (S1, filled) and total (ST, hatched outline) indices
4698
+ of every uncertain parameter, per envelope combo (one figure per combo).
4699
+
4700
+ Parameters mirror the return values of
4701
+ D0FUS_uncertainty.run_sobol_from_file.
4702
+ """
4703
+ figs = []
4704
+ for label, per_out in sobol.items():
4705
+ outputs = [k for k in per_out if np.isfinite(per_out[k]['ST']).any()]
4706
+ if not outputs:
4707
+ continue
4708
+ ncol = min(3, len(outputs))
4709
+ nrow = int(np.ceil(len(outputs) / ncol))
4710
+ fig, axes = plt.subplots(nrow, ncol,
4711
+ figsize=(4.2 * ncol, 0.6 * len(names) * nrow + 1.8),
4712
+ squeeze=False)
4713
+ y = np.arange(len(names))
4714
+ for k, out_key in enumerate(outputs):
4715
+ ax = axes[k // ncol][k % ncol]
4716
+ idx = per_out[out_key]
4717
+ order = np.argsort(np.nan_to_num(idx['ST']))
4718
+ ax.barh(y - 0.18, idx['ST'][order], height=0.36, color='lightsteelblue',
4719
+ edgecolor='navy', hatch='//', label='ST (total)')
4720
+ ax.barh(y + 0.18, idx['S1'][order], height=0.36, color='steelblue',
4721
+ label='S1 (first order)')
4722
+ ax.set_yticks(y)
4723
+ ax.set_yticklabels([names[j] for j in order], fontsize=8)
4724
+ ax.axvline(0.0, color='k', lw=0.6)
4725
+ ax.set_xlim(left=min(0.0, np.nanmin(idx['S1']) - 0.05),
4726
+ right=max(1.0, np.nanmax(idx['ST']) + 0.05))
4727
+ _std = idx.get('std', None)
4728
+ ax.set_title(out_key if _std is None
4729
+ else f"{out_key} (std = {_std:.3g})", fontsize=10)
4730
+ ax.grid(axis='x', alpha=0.3)
4731
+ if k == 0:
4732
+ ax.legend(fontsize=8, loc='lower right')
4733
+ for k in range(len(outputs), nrow * ncol):
4734
+ axes[k // ncol][k % ncol].axis('off')
4735
+ fig.suptitle(f"Sobol indices — combo [{label}] "
4736
+ f"(N={meta['n_base']}, {meta['n_eval']} evaluations)",
4737
+ fontsize=11)
4738
+ fig.tight_layout(rect=(0, 0, 1, 0.96))
4739
+ if save_dir is not None:
4740
+ safe = label.replace(' ', '').replace(',', '_').replace('=', '-')
4741
+ fig.savefig(os.path.join(save_dir, f"sobol_{safe}.png"), dpi=170)
4742
+ figs.append(fig)
4743
+ if not show:
4744
+ plt.close(fig)
4745
+ return figs
4746
+
4747
+
4748
+ # (os, Counter, numpy, matplotlib, pyplot and Patch are all exported by
4749
+ # D0FUS_import.py through the wildcard import at the top of this module.)
4750
+
4751
+ _GREEN, _AMBER, _RED = 'tab:green', 'tab:orange', 'tab:red'
4752
+ _BIND_COLOR = {'greenwald': 'tab:blue', 'troyon': 'tab:red',
4753
+ 'kink': 'tab:purple', 'build': 'tab:brown'}
4754
+ _BIND_LABEL = {'greenwald': 'Greenwald-limited', 'troyon': 'Troyon-limited',
4755
+ 'kink': 'kink-limited', 'build': 'build infeasible'}
4756
+ # nice axis names for the scan
4757
+ _NICE = {'P_fus': 'fusion power $P_{fus}$', 'R0': 'major radius $R_0$',
4758
+ 'a': 'minor radius $a$', 'Tbar': r'temperature $\langle T \rangle$'}
4759
+ _UNIT = {'P_fus': '[MW]', 'R0': '[m]', 'a': '[m]', 'Tbar': '[keV]'}
4760
+
4761
+
4762
+ def _save(fig, save_dir, fname):
4763
+ if save_dir:
4764
+ os.makedirs(save_dir, exist_ok=True)
4765
+ fig.savefig(os.path.join(save_dir, f"{fname}.png"), dpi=150, bbox_inches='tight')
4766
+ return fig
4767
+
4768
+
4769
+ def _zone_color(p):
4770
+ return _GREEN if p >= 85 else (_AMBER if p >= 60 else _RED)
4771
+
4772
+
4773
+ # =============================================================================
4774
+ # Figure 1 -- robustness verdict (single decomposition bar)
4775
+ # =============================================================================
4776
+ def fig_robustness(results, save_dir=None):
4777
+ """Decompose the Monte-Carlo into feasible / per-limit infeasible / non-converged."""
4778
+ all_rows = [r for k in results for r in results[k]]
4779
+ n = max(len(all_rows), 1)
4780
+ conv = [r for r in all_rows if r.get('converged')]
4781
+ feas = [r for r in conv if r.get('feasible')]
4782
+ n_noconv = len(all_rows) - len(conv)
4783
+ binding = Counter(r.get('binding') for r in conv if not r.get('feasible'))
4784
+
4785
+ seg = [('feasible', len(feas), _GREEN)]
4786
+ for c in ['greenwald', 'troyon', 'kink', 'build']:
4787
+ if binding.get(c, 0):
4788
+ seg.append((_BIND_LABEL[c], binding[c], _BIND_COLOR[c]))
4789
+ if n_noconv:
4790
+ seg.append(('did not converge', n_noconv, 'lightgray'))
4791
+
4792
+ fig, ax = plt.subplots(figsize=(11, 3.4))
4793
+ left = 0.0
4794
+ for label, count, col in seg:
4795
+ w = 100.0 * count / n
4796
+ ax.barh(0, w, left=left, color=col, edgecolor='white', height=0.5)
4797
+ if w >= 4:
4798
+ ax.text(left + w / 2, 0, f'{w:.0f}%', ha='center', va='center',
4799
+ fontsize=11, fontweight='bold',
4800
+ color='white' if col != 'lightgray' else 'black')
4801
+ left += w
4802
+
4803
+ p_feas = 100.0 * len(feas) / n
4804
+ verdict = ('LARGELY FEASIBLE' if p_feas >= 85 else
4805
+ 'MARGINAL' if p_feas >= 60 else 'AT RISK')
4806
+ ax.set_xlim(0, 100)
4807
+ ax.set_ylim(-0.5, 0.55)
4808
+ ax.set_yticks([])
4809
+ ax.set_xlabel('share of Monte-Carlo samples [%]', fontsize=12)
4810
+ ax.set_title(f'Design robustness: {p_feas:.0f}% feasible over N = {n} samples'
4811
+ f' -- verdict: {verdict}', fontsize=13, fontweight='bold')
4812
+
4813
+ # plain-language reading note
4814
+ if binding:
4815
+ top = max(binding, key=binding.get)
4816
+ cause = _BIND_LABEL.get(top, top).replace('-limited', ' limit').replace(' infeasible', '')
4817
+ note = f"about {p_feas:.0f} designs out of 100 stay within every limit; " \
4818
+ f"the rest are mostly held back by the {cause}"
4819
+ else:
4820
+ note = f"about {p_feas:.0f} designs out of 100 stay within every limit"
4821
+ if n_noconv:
4822
+ note += "; grey = solver did not converge at extreme corners"
4823
+ ax.annotate(note, xy=(0.5, -0.55), xycoords='axes fraction', ha='center',
4824
+ fontsize=10, color='dimgray')
4825
+
4826
+ ax.legend(handles=[Patch(color=c, label=l) for l, _, c in seg],
4827
+ fontsize=9, ncol=len(seg), loc='upper center',
4828
+ bbox_to_anchor=(0.5, -0.55))
4829
+ plt.tight_layout()
4830
+ return _save(fig, save_dir, 'uq_robustness')
4831
+
4832
+
4833
+ # =============================================================================
4834
+ # Figure 2 -- headroom to each limit (margin spread)
4835
+ # =============================================================================
4836
+ def fig_margins(results, save_dir=None):
4837
+ """P5/P50/P95 of the normalised margin to each continuous-margin limit."""
4838
+ rows = [r for k in results for r in results[k] if r.get('converged')]
4839
+ margins = {
4840
+ 'Greenwald': [r.get('gw_margin', np.nan) for r in rows],
4841
+ 'Troyon': [r.get('troyon_margin', np.nan) for r in rows],
4842
+ 'Kink (q95)': [r.get('kink_margin', np.nan) for r in rows],
4843
+ }
4844
+ ynames = list(margins)
4845
+ y = np.arange(len(ynames))
4846
+
4847
+ fig, ax = plt.subplots(figsize=(9.5, 4.2))
4848
+ # safe side shading (everything right of the limit)
4849
+ ax.axvspan(0, 1.0, color=_GREEN, alpha=0.05)
4850
+ for yi, name in zip(y, ynames):
4851
+ a = np.array([v for v in margins[name] if np.isfinite(v)])
4852
+ if a.size == 0:
4853
+ continue
4854
+ p5, p50, p95 = np.percentile(a, [5, 50, 95])
4855
+ col = _RED if p5 < 0 else (_AMBER if p5 < 0.05 else _GREEN)
4856
+ ax.plot([p5, p95], [yi, yi], color=col, lw=8, alpha=0.45, solid_capstyle='round')
4857
+ ax.plot(p50, yi, 'o', color=col, ms=11)
4858
+ ax.text(p95 + 0.015, yi, f'P50={p50:+.2f}', va='center', fontsize=9.5)
4859
+ ax.axvline(0, color='k', lw=1.4)
4860
+ ax.annotate('at the limit', xy=(0, len(ynames) - 0.45), fontsize=9,
4861
+ color='k', ha='center', va='bottom')
4862
+ ax.set_yticks(y)
4863
+ ax.set_yticklabels(ynames, fontsize=12)
4864
+ ax.set_ylim(-0.7, len(ynames) - 0.2)
4865
+ ax.set_xlabel('headroom to the limit (0 = at the limit, further right = safer)',
4866
+ fontsize=11)
4867
+ ax.set_title('Headroom to each plasma limit under uncertainty',
4868
+ fontsize=13, fontweight='bold')
4869
+ ax.legend(handles=[Patch(color=_GREEN, label='comfortable margin'),
4870
+ Patch(color=_AMBER, label='tight margin'),
4871
+ Patch(color=_RED, label='margin can reach the limit')],
4872
+ fontsize=9, ncol=3, loc='upper center', bbox_to_anchor=(0.5, -0.26))
4873
+ ax.annotate('bar = P5 to P95 over the Monte-Carlo; touching the line on the left '
4874
+ 'means that limit can be crossed',
4875
+ xy=(0.5, -0.44), xycoords='axes fraction', ha='center',
4876
+ fontsize=9.5, color='dimgray')
4877
+ plt.tight_layout()
4878
+ return _save(fig, save_dir, 'uq_margins')
4879
+
4880
+
4881
+ # =============================================================================
4882
+ # Figure 3 -- one-parameter feasibility scan (Monte-Carlo at each value)
4883
+ # =============================================================================
4884
+ def scan_feasibility(uq_file, scan_specs, n_samples=200, n_jobs=-1, combo=None, seed=0, verbose=10):
4885
+ """
4886
+ Sweep each design parameter and run a Monte-Carlo over all the other uncertain
4887
+ inputs at every value. A common LHS sample is reused across a parameter's scan
4888
+ points (common random numbers) so the curve is smooth. The whole scan is evaluated
4889
+ in ONE parallel pass over all (parameter, point, sample) tasks, which avoids the
4890
+ overhead of opening a separate worker pool at every scan point.
4891
+
4892
+ scan_specs : {param: (lo, hi, n_points)}
4893
+ Returns : {param: (x_values, P_feasible[%], design_value)}
4894
+ """
4895
+ from D0FUS_EXE import D0FUS_uncertainty as UQ
4896
+
4897
+ base, spec, envelope, controls, deck_path = UQ.parse_uq_file(uq_file)
4898
+ combo = combo or {}
4899
+
4900
+ grids, tasks = {}, []
4901
+ for p, (lo, hi, npts) in scan_specs.items():
4902
+ reduced = {k: v for k, v in spec.items() if k != p} # exclude the scanned input
4903
+ names = list(reduced.keys())
4904
+ _, X = UQ.sample_lhs(reduced, n_samples, seed=seed)
4905
+ xs = np.unique(np.concatenate([np.linspace(lo, hi, npts),
4906
+ [float(getattr(base, p))]])) # design value on grid
4907
+ grids[p] = (xs, float(getattr(base, p)))
4908
+ for x in xs:
4909
+ for i in range(n_samples):
4910
+ tasks.append((deck_path, names, X[i], {**combo, p: x}))
4911
+
4912
+ # Single tqdm bar in place of joblib's per-batch log lines. return_as
4913
+ # 'generator' keeps submission order so the per-parameter slicing below holds.
4914
+ _gen = Parallel(n_jobs=n_jobs, return_as="generator")(delayed(UQ._uq_worker)(*t) for t in tasks)
4915
+ rows = list(tqdm(_gen, total=len(tasks), desc="Feasibility scan",
4916
+ unit="run", disable=(verbose == 0)))
4917
+
4918
+ out, idx = {}, 0
4919
+ for p, (xs, nom) in grids.items():
4920
+ pf = []
4921
+ for _ in xs:
4922
+ chunk = rows[idx:idx + n_samples]
4923
+ idx += n_samples
4924
+ feas = [r for r in chunk if r.get('converged') and r.get('feasible')]
4925
+ pf.append(100.0 * len(feas) / n_samples) # over all samples
4926
+ out[p] = (xs, np.array(pf), nom)
4927
+ return out
4928
+
4929
+
4930
+ def fig_scan(scan_results, save_dir=None):
4931
+ """Four-panel feasibility scan with a traffic-light background; a vertical band
4932
+ marks the design value of each parameter."""
4933
+ params = list(scan_results)
4934
+ ncols = 2
4935
+ nrows = int(np.ceil(len(params) / ncols))
4936
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5.7 * ncols, 3.9 * nrows))
4937
+ axes = np.atleast_1d(axes).ravel()
4938
+
4939
+ for ax, p in zip(axes, params):
4940
+ xs, pf, nom = scan_results[p]
4941
+ # traffic-light zones
4942
+ ax.axhspan(85, 105, color=_GREEN, alpha=0.10)
4943
+ ax.axhspan(60, 85, color=_AMBER, alpha=0.10)
4944
+ ax.axhspan(0, 60, color=_RED, alpha=0.10)
4945
+ ax.plot(xs, pf, '-o', color='black', lw=2, ms=3, zorder=4)
4946
+ # vertical band marking the design value
4947
+ ax.axvline(nom, color='0.15', ls='--', lw=1.6, zorder=5)
4948
+ ax.text(nom, 50, ' design value ', rotation=90, va='center', ha='center',
4949
+ fontsize=8.5, color='0.15', zorder=6,
4950
+ bbox=dict(boxstyle='round,pad=0.15', fc='white', ec='0.6', alpha=0.85))
4951
+ ax.set_xlabel(f"{_NICE.get(p, p)} {_UNIT.get(p, '')}", fontsize=11)
4952
+ ax.set_ylabel('chance of staying feasible [%]', fontsize=10)
4953
+ ax.set_ylim(0, 105)
4954
+ ax.set_xlim(xs.min(), xs.max())
4955
+
4956
+ for ax in axes[len(params):]:
4957
+ ax.axis('off')
4958
+
4959
+ plt.suptitle('How feasibility responds to each design choice',
4960
+ fontsize=14, fontweight='bold')
4961
+ fig.legend(handles=[Patch(color=_GREEN, alpha=0.35, label='safe (>= 85%)'),
4962
+ Patch(color=_AMBER, alpha=0.35, label='marginal (60-85%)'),
4963
+ Patch(color=_RED, alpha=0.35, label='unlikely (< 60%)')],
4964
+ loc='lower center', ncol=3, fontsize=9, frameon=False,
4965
+ bbox_to_anchor=(0.5, -0.02))
4966
+ plt.tight_layout(rect=[0, 0.04, 1, 0.96])
4967
+ return _save(fig, save_dir, 'uq_scan_feasibility')
4968
+
4969
+
4970
+ # =============================================================================
4971
+ # Figure 4 -- impact of the physics models (envelope combinations)
4972
+ # =============================================================================
4973
+ def fig_models(results, qoi='Q', save_dir=None):
4974
+ """
4975
+ How the model-form choices (confinement scaling law, elongation model, ...) move
4976
+ feasibility. One row per model combination: a wide spread between rows means the
4977
+ model assumptions matter, a tight spread means they do not. (qoi kept for backward
4978
+ compatibility; no longer plotted.)
4979
+ """
4980
+ combos = [k for k in results if k != ('nominal',)]
4981
+ if not combos:
4982
+ return None # no model envelope in this study
4983
+
4984
+ def _label(key):
4985
+ d = dict(key)
4986
+ order = ['Scaling_Law', 'Option_Kappa', 'Bootstrap_choice']
4987
+ vals = [str(d[k]) for k in order if k in d]
4988
+ vals += [str(v) for k, v in key if k not in order]
4989
+ return ' · '.join(vals)
4990
+
4991
+ labels, p_feas = [], []
4992
+ for k in combos:
4993
+ rws = results[k]
4994
+ n = max(len(rws), 1)
4995
+ feas = [r for r in rws if r.get('converged') and r.get('feasible')]
4996
+ labels.append(_label(k))
4997
+ p_feas.append(100.0 * len(feas) / n)
4998
+
4999
+ order = np.argsort(p_feas) # worst feasibility at the bottom
5000
+ labels = [labels[i] for i in order]
5001
+ p_feas = [p_feas[i] for i in order]
5002
+ y = np.arange(len(labels))
5003
+
5004
+ fig, ax = plt.subplots(figsize=(10, 0.7 * len(labels) + 2.2))
5005
+ grays = [str(max(0.25, 0.85 - 0.6 * p / 100.0)) for p in p_feas] # darker = higher
5006
+ ax.barh(y, p_feas, color=grays, edgecolor='black', lw=0.8)
5007
+ for yi, p in zip(y, p_feas):
5008
+ ax.text(min(p + 1.5, 97), yi, f'{p:.0f}%', va='center', fontsize=10)
5009
+ ax.axvline(85, color='0.3', ls=':', lw=1)
5010
+ ax.set_xlim(0, 105)
5011
+ ax.set_yticks(y)
5012
+ ax.set_yticklabels(labels, fontsize=11)
5013
+ ax.set_xlabel('probability of staying feasible [%]', fontsize=11)
5014
+ ax.set_title('Impact of the physics models on feasibility',
5015
+ fontsize=13, fontweight='bold')
5016
+ ax.annotate('each row is one model combination (confinement scaling · elongation); '
5017
+ 'the spread shows how much the model assumptions drive feasibility',
5018
+ xy=(0.5, -0.30 if len(labels) <= 4 else -0.18), xycoords='axes fraction',
5019
+ ha='center', fontsize=9.5, color='dimgray')
5020
+ plt.tight_layout()
5021
+ return _save(fig, save_dir, 'uq_models')
5022
+
5023
+ if __name__ == "__main__":
5024
+ _standalone_main()