OpenPyTEA 1.2.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.
openpytea/analysis.py ADDED
@@ -0,0 +1,2127 @@
1
+ from tqdm import tqdm
2
+ from copy import deepcopy
3
+ from itertools import cycle
4
+ from collections.abc import Mapping
5
+ from scipy.stats import truncnorm, norm
6
+ from matplotlib.ticker import ScalarFormatter
7
+ import matplotlib.pyplot as plt
8
+ import scienceplots
9
+ import numpy as np
10
+ import re
11
+
12
+
13
+ plt.style.use(["science", "ieee"])
14
+ _ = scienceplots # mark as used for Flake8
15
+
16
+
17
+ # HELPER FUNCTIONS
18
+ def make_label(s: str) -> str:
19
+ """
20
+ Convert a string to a label format by replacing underscores with spaces
21
+ and capitalizing the first character.
22
+
23
+ Preserves LaTeX math segments (text enclosed in $...$) without
24
+ modification, while replacing underscores with spaces in non-math segments.
25
+
26
+ Args:
27
+ s: Input string that may contain underscores and
28
+ LaTeX math expressions.
29
+
30
+ Returns:
31
+ A formatted label string with underscores replaced by spaces
32
+ (outside math segments)
33
+ and the first character capitalized.
34
+
35
+ Example:
36
+ >>> make_label("my_variable_$x^2$")
37
+ "My variable $x^2$"
38
+ """
39
+ parts = re.split(r"(\$.*?\$)", s) # keep math segments
40
+ parts = [
41
+ p.replace("_", " ") if not p.startswith("$") else p
42
+ for p in parts
43
+ ]
44
+ s = "".join(parts)
45
+ return s[:1].upper() + s[1:]
46
+
47
+
48
+ def try_clear_output(*args, **kwargs):
49
+ """
50
+ Attempt to clear the output of the current IPython cell.
51
+ This function tries to import and call `clear_output` from IPython.display.
52
+ If IPython is not available, the function silently passes without
53
+ raising an error.
54
+ Args:
55
+ *args:
56
+ Variable length args list passed to IPython's clear_output function.
57
+ **kwargs:
58
+ Arbitrary keyword args passed to IPython's clear_output function.
59
+ Common kwargs include:
60
+ - wait (bool): If True, wait for next cell output before clearing.
61
+ Returns:
62
+ None
63
+ Note:
64
+ This function is useful in environments where
65
+ IPython may not be installed,
66
+ allowing code to run without raising ImportError exceptions.
67
+ """
68
+ try:
69
+ from IPython.display import clear_output
70
+
71
+ clear_output(*args, **kwargs)
72
+ except ImportError:
73
+ pass
74
+
75
+
76
+ def get_original_value(plant, full_key):
77
+ """
78
+ Retrieve the original value from a nested structure
79
+ using a dot-separated key path.
80
+
81
+ This function navigates through a potentially nested
82
+ combination of dictionaries and objects to extract a value
83
+ at the location specified by the full_key parameter.
84
+
85
+ Args:
86
+ plant: The root object or dictionary to traverse.
87
+ Can be either a dictionary or an object with attributes.
88
+ full_key (str):
89
+ A dot-separated string representing the path to the value
90
+ (e.g., "level1.level2.level3").
91
+
92
+ Returns:
93
+ The value found at the specified key path. For dictionary entries,
94
+ returns the "price" field of the value.
95
+
96
+ Raises:
97
+ KeyError: If a key is not found in a dictionary.
98
+ AttributeError: If an attribute is not found in an object.
99
+ TypeError:
100
+ If attempting to access a key/attribute on an unsupported type.
101
+
102
+ Examples:
103
+ >>> plant = {"item": {"price": 100}}
104
+ >>> get_original_value(plant, "item")
105
+ 100
106
+ """
107
+ keys = full_key.split(".")
108
+ ref = plant
109
+ for k in keys:
110
+ if isinstance(ref, dict):
111
+ ref = ref[k]["price"]
112
+ else:
113
+ ref = getattr(ref, k)
114
+ return ref
115
+
116
+
117
+ def update_and_evaluate(
118
+ plant,
119
+ factor,
120
+ value,
121
+ nested_price_keys,
122
+ metric="LCOP",
123
+ additional_capex: bool = False,
124
+ ):
125
+ """
126
+ Update a plant parameter and recalculate the specified economic metric.
127
+ This function creates a deep copy of the plant object,
128
+ applies a parameter change, recomputes the economic calculations,
129
+ and returns the requested metric.
130
+ Parameters
131
+ ----------
132
+ plant : object
133
+ The plant object to be evaluated. Must have methods for updating
134
+ configuration and calculating economic metrics.
135
+ factor : str
136
+ The parameter to update. Can be one of:
137
+ - "fixed_capital": updates fixed capital cost
138
+ - "fixed_opex": updates fixed operating expenditure
139
+ - "variable_opex_inputs.<name>": updates price of a variable input
140
+ - "plant_products.<name>": updates price of a plant product
141
+ - "operator_hourly_rate": updates operator hourly rate
142
+ - Any other top-level parameter
143
+ (e.g., "interest_rate", "project_lifetime")
144
+ value : float or dict
145
+ The new value for the parameter specified by factor.
146
+ nested_price_keys : list or set
147
+ Collection of valid nested price keys in the format "category.<name>"
148
+ (e.g., ["variable_opex_inputs.steam", "plant_products.electricity"]).
149
+ metric : str, optional
150
+ The economic metric to return. Default is "LCOP".
151
+ Supported values: "LCOP", "ROI", "NPV", "PBT", "IRR".
152
+ additional_capex : bool, optional
153
+ Whether to include additional capital expenditure in ROI and PBT
154
+ calculations. Default is False.
155
+ Returns
156
+ -------
157
+ float or array-like
158
+ The requested metric value after applying the parameter update.
159
+ - "LCOP": Levelized cost of product
160
+ - "ROI": Return on investment (%)
161
+ - "NPV": Net present value
162
+ - "PBT"/"PAYBACK"/"PAYBACK_TIME": Payback time (years)
163
+ - "IRR": Internal rate of return (%)
164
+ Raises
165
+ ------
166
+ ValueError
167
+ If an unsupported nested price root is provided or if an unsupported
168
+ metric is requested.
169
+ """
170
+ plant_copy = deepcopy(plant)
171
+ metric = metric.upper()
172
+
173
+ # --- 1. Apply parameter change ---
174
+
175
+ if factor == "fixed_capital":
176
+ plant_copy.calculate_fixed_capital(fc=value)
177
+
178
+ elif factor == "fixed_opex":
179
+ plant_copy.calculate_fixed_opex(fp=value)
180
+
181
+ elif factor in nested_price_keys:
182
+ # factor can be:
183
+ # "variable_opex_inputs.<name>" or
184
+ # "plant_products.<name>"
185
+ parts = factor.split(
186
+ "."
187
+ ) # ['variable_opex_inputs' | 'plant_products', '<name>']
188
+ root, name = parts[0], parts[1]
189
+
190
+ if root == "variable_opex_inputs":
191
+ config = {
192
+ "variable_opex_inputs": {
193
+ name: {
194
+ "price": value,
195
+ }
196
+ }
197
+ }
198
+ elif root == "plant_products":
199
+ config = {
200
+ "plant_products": {
201
+ name: {
202
+ "price": value,
203
+ }
204
+ }
205
+ }
206
+ else:
207
+ raise ValueError(
208
+ f"Unsupported nested price root '{root}' in factor '{factor}'."
209
+ )
210
+
211
+ plant_copy.update_configuration(config)
212
+
213
+ elif factor == "operator_hourly_rate":
214
+ # Support both dict-style {"rate": ...} and
215
+ # scalar-style operator_hourly_rate
216
+ current = getattr(
217
+ plant_copy, "operator_hourly_rate", None
218
+ )
219
+ if isinstance(current, dict):
220
+ config = {
221
+ "operator_hourly_rate": {"rate": value}
222
+ }
223
+ else:
224
+ config = {"operator_hourly_rate": value}
225
+ plant_copy.update_configuration(config)
226
+
227
+ else:
228
+ # Generic top-level parameter update,
229
+ # e.g. 'interest_rate', 'project_lifetime'
230
+ config = {factor: value}
231
+ plant_copy.update_configuration(config)
232
+
233
+ # --- 2. Recompute economics ---
234
+
235
+ # This builds fixed_capital, opex, revenue, cash_flow, etc.
236
+ plant_copy.calculate_levelized_cost()
237
+
238
+ # --- 3. Return requested metric ---
239
+
240
+ if metric == "LCOP":
241
+ return plant_copy.levelized_cost
242
+
243
+ elif metric == "ROI":
244
+ plant_copy.calculate_roi(
245
+ additional_capex=additional_capex
246
+ )
247
+ return plant_copy.roi
248
+
249
+ elif metric == "NPV":
250
+ # With MC-aware calculate_npv this can be scalar or array.
251
+ # In sensitivity/tornado we are effectively in a single-scenario.
252
+ return plant_copy.calculate_npv()
253
+
254
+ elif metric in ("PBT", "PAYBACK", "PAYBACK_TIME"):
255
+ return plant_copy.calculate_payback_time(
256
+ additional_capex=additional_capex
257
+ )
258
+ elif metric == "IRR":
259
+ plant_copy.calculate_irr()
260
+ return plant_copy.irr
261
+
262
+ else:
263
+ raise ValueError(
264
+ f"Unsupported metric '{metric}'. \n"
265
+ f"Use 'LCOP', 'ROI', 'NPV', 'PBT', or 'IRR'."
266
+ )
267
+
268
+
269
+ def _plot_stacked_bar_from_components(
270
+ components,
271
+ xlabel,
272
+ ylabel: str,
273
+ figsize=(1.2, 1.8),
274
+ pct: bool = False,
275
+ ax=None,
276
+ show: bool = True,
277
+ ):
278
+ """
279
+ Plot a stacked bar chart from component data.
280
+ Creates a stacked bar chart visualization from component dictionaries,
281
+ with automatic color mapping and legend generation. Supports single or
282
+ multiple bars with optional percentage normalization.
283
+ Parameters
284
+ ----------
285
+ components : dict or list of dict
286
+ Component data where keys are component names
287
+ and values are numeric values.
288
+ If a single dict is provided,
289
+ it is converted to a list with one element.
290
+ xlabel : str or list of str
291
+ Label(s) for the x-axis.
292
+ If a string is provided and multiple bars exist,
293
+ labels are auto-generated as "{xlabel} 1", "{xlabel} 2", etc.
294
+ ylabel : str
295
+ Base label for the y-axis. Units are automatically appended ("[%]" for
296
+ percentages or "[$]" for absolute values).
297
+ figsize : tuple of float, optional
298
+ Figure size as (width, height). Default is (1.2, 1.8).
299
+ Width is automatically adjusted based on the number of bars.
300
+ pct : bool, optional
301
+ If True, normalize values to percentages per bar. Default is False.
302
+ ax : matplotlib.axes.Axes, optional
303
+ Existing axes object to plot on.
304
+ If None, a new figure and axes are created.
305
+ Default is None.
306
+ show : bool, optional
307
+ If True and a new figure was created, display the plot.
308
+ Default is True.
309
+ Returns
310
+ -------
311
+ matplotlib.axes.Axes
312
+ The axes object containing the plot.
313
+ Raises
314
+ ------
315
+ ValueError
316
+ If components list is empty, all component dictionaries are empty,
317
+ the number of xlabels does not match the number of bars, or if
318
+ percentage computation is requested with zero total values.
319
+ Notes
320
+ -----
321
+ - Components are sorted by total value across all bars (descending).
322
+ - Colors are assigned from the plasma colormap.
323
+ - Legend is positioned outside the plot area on the right side.
324
+ - For single bars with percentages, the value is displayed in the label.
325
+ - Scientific notation is used for y-labels when displaying absolute values.
326
+ """
327
+ # --- Normalize to list of dicts ---
328
+ if isinstance(components, Mapping):
329
+ components_list = [dict(components)]
330
+ else:
331
+ components_list = [dict(c) for c in components]
332
+
333
+ if not components_list:
334
+ raise ValueError(
335
+ "No components to plot (empty list)."
336
+ )
337
+
338
+ n_bars = len(components_list)
339
+
340
+ # --- Normalize xlabels ---
341
+ if isinstance(xlabel, str):
342
+ if n_bars == 1:
343
+ xlabels = [xlabel]
344
+ else:
345
+ xlabels = [
346
+ f"{xlabel} {i+1}" for i in range(n_bars)
347
+ ]
348
+ else:
349
+ xlabels = list(xlabel)
350
+ if len(xlabels) != n_bars:
351
+ raise ValueError(
352
+ "Number of xlabels must match number of bars."
353
+ )
354
+
355
+ # --- Convert values to percentages per bar if requested ---
356
+ if pct:
357
+ converted = []
358
+ for comp in components_list:
359
+ vals = np.array(
360
+ list(comp.values()), dtype=float
361
+ )
362
+ total = vals.sum()
363
+ if total == 0:
364
+ raise ValueError(
365
+ "Cannot compute percentages: total value "
366
+ "is zero in one bar."
367
+ )
368
+ factor = 100.0 / total
369
+ converted.append(
370
+ {
371
+ k: float(v) * factor
372
+ for k, v in comp.items()
373
+ }
374
+ )
375
+ components_list = converted
376
+ ylabel = ylabel + r" / [\%]"
377
+ else:
378
+ ylabel = ylabel + r" / [\$]"
379
+
380
+ # --- Collect all component names across all bars ---
381
+ all_names = set()
382
+ for comp in components_list:
383
+ all_names.update(comp.keys())
384
+ if not all_names:
385
+ raise ValueError(
386
+ "All component dictionaries are empty."
387
+ )
388
+
389
+ totals = {
390
+ name: sum(
391
+ float(comp.get(name, 0.0))
392
+ for comp in components_list
393
+ )
394
+ for name in all_names
395
+ }
396
+ names_sorted = sorted(
397
+ all_names, key=lambda n: totals[n], reverse=True
398
+ )
399
+
400
+ cmap = plt.cm.plasma
401
+ colors = [
402
+ cmap(i)
403
+ for i in np.linspace(0.15, 0.95, len(names_sorted))
404
+ ]
405
+ color_map = dict(zip(names_sorted, colors))
406
+
407
+ spacing = 0.75 # < 1.0 pulls bars together
408
+ bar_width = 0.45
409
+ x = np.arange(n_bars) * spacing
410
+ bottoms = np.zeros(n_bars, dtype=float)
411
+
412
+ # --- Ax/fig handling ---
413
+ created_fig = None
414
+ if ax is None:
415
+ if (
416
+ isinstance(figsize, (tuple, list))
417
+ and len(figsize) == 2
418
+ ):
419
+ base_w, base_h = figsize
420
+ else:
421
+ base_w, base_h = 1.2, 1.8
422
+ auto_width = max(base_w * n_bars, base_w)
423
+ created_fig, ax = plt.subplots(
424
+ figsize=(auto_width, base_h)
425
+ )
426
+
427
+ # --- Draw stacked bars ---
428
+ for name in names_sorted:
429
+ vals = np.array(
430
+ [
431
+ comp.get(name, 0.0)
432
+ for comp in components_list
433
+ ],
434
+ dtype=float,
435
+ )
436
+ if np.allclose(vals, 0.0):
437
+ continue
438
+
439
+ if n_bars == 1 and pct:
440
+ label = rf"{name} ({vals[0]:.1f}\%)"
441
+ else:
442
+ label = name
443
+
444
+ ax.bar(
445
+ x,
446
+ vals,
447
+ bottom=bottoms,
448
+ width=bar_width,
449
+ color=color_map[name],
450
+ edgecolor="black",
451
+ linewidth=0.3,
452
+ label=label,
453
+ )
454
+ bottoms += vals
455
+
456
+ ax.set_ylabel(ylabel)
457
+ ax.set_xticks(x)
458
+ ax.set_xticklabels(xlabels)
459
+
460
+ left = x[0] - bar_width / 2
461
+ right = x[-1] + bar_width / 2
462
+ ax.set_xlim(left - 0.2, right + 0.2)
463
+
464
+ ax.legend(
465
+ loc="center left",
466
+ bbox_to_anchor=(1.02, 0.5),
467
+ fontsize="x-small",
468
+ frameon=False,
469
+ )
470
+
471
+ if not pct:
472
+ formatter = ScalarFormatter(useMathText=True)
473
+ formatter.set_scientific(True)
474
+ formatter.set_powerlimits((0, 0))
475
+ ax.yaxis.set_major_formatter(formatter)
476
+
477
+ if show and created_fig is not None:
478
+ plt.show()
479
+
480
+ return ax
481
+
482
+
483
+ def plot_direct_costs_bar(
484
+ plants,
485
+ figsize=(1.2, 1.8),
486
+ pct: bool = False,
487
+ ax=None,
488
+ show=True,
489
+ ):
490
+ """
491
+ Plot a stacked bar chart of direct costs for given plant(s).
492
+ Parameters
493
+ ----------
494
+ plants : Plant or list of Plant or tuple of Plant
495
+ One or more Plant objects to plot direct costs for.
496
+ figsize : tuple of float, optional
497
+ Figure size as (width, height) in inches. Default is (1.2, 1.8).
498
+ pct : bool, optional
499
+ If True, display costs as percentages.
500
+ If False, display absolute values. Default is False.
501
+ ax : matplotlib.axes.Axes, optional
502
+ Matplotlib axes object to plot on. If None, a new figure is created.
503
+ Default is None.
504
+ show : bool, optional
505
+ If True, display the plot. If False, only create the plot object.
506
+ Default is True.
507
+ Returns
508
+ -------
509
+ None
510
+ Displays or creates a stacked bar chart of direct costs by equipment.
511
+ Notes
512
+ -----
513
+ Direct costs are aggregated by equipment name across all plants.
514
+ Each plant is represented as a separate bar in the chart.
515
+ """
516
+ if not isinstance(plants, (list, tuple)):
517
+ plants = [plants]
518
+
519
+ components_list = []
520
+ xlabels = []
521
+
522
+ for plant in plants:
523
+ components = {}
524
+ for eq in plant.equipment_list:
525
+ components[eq.name] = float(eq.direct_cost)
526
+
527
+ components_list.append(components)
528
+ xlabels.append(plant.name)
529
+
530
+ _plot_stacked_bar_from_components(
531
+ components=components_list,
532
+ xlabel=xlabels,
533
+ ylabel=r"Direct costs",
534
+ figsize=figsize,
535
+ pct=pct,
536
+ ax=ax,
537
+ show=show,
538
+ )
539
+
540
+
541
+ def plot_fixed_capital_bar(
542
+ plants,
543
+ figsize=(1.2, 1.8),
544
+ additional_capex: bool = False,
545
+ pct: bool = False,
546
+ ax=None,
547
+ show=True,
548
+ ):
549
+ """
550
+ Plot a stacked bar chart of fixed capital costs for one or more plants.
551
+ Parameters
552
+ ----------
553
+ plants : Plant or list of Plant or tuple of Plant
554
+ One or more plant objects to plot fixed capital costs for.
555
+ figsize : tuple, optional
556
+ Figure size as (width, height) in inches. Default is (1.2, 1.8).
557
+ additional_capex : bool, optional
558
+ If True, include additional CAPEX costs in the plot. Default is False.
559
+ pct : bool, optional
560
+ If True, display values as percentages. Default is False.
561
+ ax : matplotlib.axes.Axes, optional
562
+ Matplotlib axes object to plot on. If None, a new figure is created.
563
+ Default is None.
564
+ show : bool, optional
565
+ If True, display the plot. Default is True.
566
+ Returns
567
+ -------
568
+ None
569
+ Notes
570
+ -----
571
+ The function calculates fixed capital for each plant and displays a stacked
572
+ bar chart with the following components:
573
+ - ISBL (Inside Battery Limits)
574
+ - OSBL (Outside Battery Limits)
575
+ - Design & engineering
576
+ - Contingency
577
+ - Additional CAPEX (if additional_capex=True and cost is non-zero)
578
+ The function calls calculate_fixed_capital() internally for each plant.
579
+ """
580
+ if not isinstance(plants, (list, tuple)):
581
+ plants = [plants]
582
+
583
+ components_list = []
584
+ xlabels = []
585
+
586
+ for plant in plants:
587
+ plant.calculate_fixed_capital(fc=None)
588
+
589
+ components = {
590
+ "ISBL": plant.isbl,
591
+ "OSBL": plant.osbl,
592
+ r"Design \& engineering": plant.dne,
593
+ "Contingency": plant.contigency,
594
+ }
595
+
596
+ if additional_capex:
597
+
598
+ extra = getattr(
599
+ plant, "additional_capex_cost", None
600
+ )
601
+
602
+ if extra is None:
603
+ total_extra = 0.0
604
+
605
+ elif isinstance(extra, (list, tuple)):
606
+ total_extra = (
607
+ float(sum(extra))
608
+ if len(extra) > 0
609
+ else 0.0
610
+ )
611
+
612
+ else:
613
+ # numeric single value (int, float, numpy scalar, etc.)
614
+ try:
615
+ total_extra = float(extra)
616
+ except Exception:
617
+ total_extra = 0.0 # fallback if something unexpected
618
+
619
+ # add only if nonzero
620
+ if total_extra != 0:
621
+ components["Additional CAPEX"] = total_extra
622
+
623
+ components_list.append(components)
624
+ xlabels.append(plant.name)
625
+
626
+ _plot_stacked_bar_from_components(
627
+ components=components_list,
628
+ xlabel=xlabels,
629
+ ylabel=r"Fixed CAPEX",
630
+ figsize=figsize,
631
+ pct=pct,
632
+ ax=ax,
633
+ show=show,
634
+ )
635
+
636
+
637
+ def plot_variable_opex_bar(
638
+ plants,
639
+ figsize=(1.2, 1.8),
640
+ pct: bool = False,
641
+ ax=None,
642
+ show=True,
643
+ ):
644
+ """
645
+ Plot a stacked bar chart of variable OPEX for one or more plants.
646
+ This function visualizes the annual variable oOPEX broken down by component
647
+ for each plant. It extracts cost information from plant variable OPEX
648
+ inputs and displays them as a stacked bar chart.
649
+ Parameters
650
+ ----------
651
+ plants : Plant or list of Plant
652
+ A single plant object or list of plant objects to plot.
653
+ If a single plant is provided, it will be converted to a list.
654
+ figsize : tuple, optional
655
+ Figure size as (width, height) in inches. Default is (1.2, 1.8).
656
+ pct : bool, optional
657
+ If True, display values as percentages of the total.
658
+ If False, display absolute values. Default is False.
659
+ ax : matplotlib.axes.Axes, optional
660
+ Matplotlib axes object to plot on.
661
+ If None, a new figure and axes will be created.
662
+ Default is None.
663
+ show : bool, optional
664
+ If True, display the plot.
665
+ If False, only create the plot object without displaying.
666
+ Default is True.
667
+ Notes
668
+ -----
669
+ - Cost values are extracted from plant variable OPEX inputs
670
+ in the following priority:
671
+ 1. 'annual_cost' key if present
672
+ 2. 'cost' key if present
673
+ 3. Product of 'consumption' and 'price' keys if both present
674
+ - Names are formatted by removing underscores and capitalizing 1st letter.
675
+ - Components with no cost data are skipped.
676
+ Returns
677
+ -------
678
+ None
679
+ Displays the plot according to the `show` parameter.
680
+ """
681
+ if not isinstance(plants, (list, tuple)):
682
+ plants = [plants]
683
+
684
+ components_list = []
685
+ xlabels = []
686
+
687
+ for plant in plants:
688
+ components = {}
689
+
690
+ for (
691
+ name,
692
+ props,
693
+ ) in plant.variable_opex_inputs.items():
694
+ if "annual_cost" in props:
695
+ val = props["annual_cost"]
696
+ elif "cost" in props:
697
+ val = props["cost"]
698
+ elif (
699
+ "consumption" in props and "price" in props
700
+ ):
701
+ val = props["consumption"] * props["price"]
702
+ else:
703
+ continue
704
+
705
+ # Format label: remove underscores and capitalize first letter
706
+ label = make_label(name)
707
+ components[label] = float(val)
708
+
709
+ components_list.append(components)
710
+ xlabels.append(plant.name)
711
+
712
+ _plot_stacked_bar_from_components(
713
+ components=components_list,
714
+ xlabel=xlabels,
715
+ ylabel=r"Annual variable OPEX",
716
+ figsize=figsize,
717
+ pct=pct,
718
+ ax=ax,
719
+ show=show,
720
+ )
721
+
722
+
723
+ def plot_fixed_opex_bar(
724
+ plants,
725
+ figsize=(1.2, 1.8),
726
+ pct: bool = False,
727
+ ax=None,
728
+ show=True,
729
+ ):
730
+ """
731
+ Plot a stacked bar chart of fixed OPEX for one or more plants.
732
+ This function calculates and visualizes the breakdown of
733
+ fixed OPEX components including operating labor,
734
+ supervision, maintenance, taxes & insurance, and
735
+ other overhead costs for the specified plant(s).
736
+ Parameters
737
+ ----------
738
+ plants : Plant or list or tuple
739
+ A single plant object or a list/tuple of plant objects to plot.
740
+ If a single plant is provided, it will be converted to a list.
741
+ figsize : tuple, optional
742
+ Figure size as (width, height) in inches. Default is (1.2, 1.8).
743
+ pct : bool, optional
744
+ If True, display values as percentages.
745
+ If False (default), display absolute values.
746
+ ax : matplotlib.axes.Axes, optional
747
+ Matplotlib axes object to plot on.
748
+ If None, a new figure will be created.
749
+ show : bool, optional
750
+ If True (default), display the plot.
751
+ If False, the plot is not displayed.
752
+ Returns
753
+ -------
754
+ None
755
+ The function displays or stores the plot
756
+ based on the ax and show parameters.
757
+ Notes
758
+ -----
759
+ The fixed OPEX components included in the plot are:
760
+ - Operating labor
761
+ - Supervision
762
+ - Direct salary overhead
763
+ - Laboratory charges
764
+ - Maintenance
765
+ - Taxes & insurance
766
+ - Rent of land
767
+ - Environmental charges
768
+ - Operating supplies
769
+ - General plant overhead
770
+ - Interest on working capital
771
+ - Patents & royalties
772
+ - Distribution & selling
773
+ - R&D
774
+ The function calls calculate_fixed_opex() on each plant before plotting.
775
+ """
776
+ if not isinstance(plants, (list, tuple)):
777
+ plants = [plants]
778
+
779
+ components_list = []
780
+ xlabels = []
781
+
782
+ for plant in plants:
783
+ plant.calculate_fixed_opex(fp=None)
784
+
785
+ components = {
786
+ "Operating labor": plant.operating_labor_costs,
787
+ "Supervision": plant.supervision_costs,
788
+ "Direct salary overhead": plant.direct_salary_overhead,
789
+ "Laboratory charges": plant.laboratory_charges,
790
+ "Maintenance": plant.maintenance_costs,
791
+ r"Taxes \& insurance": plant.taxes_insurance_costs,
792
+ "Rent of land": plant.rent_of_land_costs,
793
+ "Environmental charges": plant.environmental_charges,
794
+ "Operating supplies": plant.operating_supplies,
795
+ "General plant overhead": plant.general_plant_overhead,
796
+ "Interest on working capital": plant.interest_working_capital,
797
+ r"Patents \& royalties": plant.patents_royalties,
798
+ r"Distribution \& selling": plant.distribution_selling_costs,
799
+ r"R\&D": plant.RnD_costs,
800
+ }
801
+
802
+ components_list.append(components)
803
+ xlabels.append(plant.name)
804
+
805
+ _plot_stacked_bar_from_components(
806
+ components=components_list,
807
+ xlabel=xlabels,
808
+ ylabel=r"Annual fixed OPEX",
809
+ figsize=figsize,
810
+ pct=pct,
811
+ ax=ax,
812
+ show=show,
813
+ )
814
+
815
+
816
+ def sensitivity_plot(
817
+ plants,
818
+ parameter,
819
+ plus_minus_value,
820
+ n_points=21,
821
+ figsize=(3.2, 2.2),
822
+ metric="LCOP",
823
+ label=None,
824
+ additional_capex: bool = False,
825
+ ax=None,
826
+ show: bool = True,
827
+ ):
828
+ """
829
+ Generate a sensitivity analysis plot for one or more plants.
830
+ This function creates a line plot showing how a specified metric changes
831
+ when a given parameter varies by a certain percentage. It supports multiple
832
+ plants and various financial/technical metrics.
833
+ Parameters
834
+ ----------
835
+ plants : Plant or list of Plant
836
+ One or more Plant objects to analyze.
837
+ parameter : str
838
+ The parameter to vary. Can be a full path (e.g.,
839
+ "variable_opex_inputs.co2_tax") or a shorthand key name.
840
+ Valid top-level parameters: "fixed_capital", "fixed_opex",
841
+ "project_lifetime", "interest_rate", "operator_hourly_rate".
842
+ plus_minus_value : float
843
+ The range of variation as a fraction (e.g., 0.2 for ±20%).
844
+ n_points : int, optional
845
+ Number of points to evaluate along the parameter range.
846
+ Default is 21.
847
+ figsize : tuple of float, optional
848
+ Figure size as (width, height) in inches. Default is (3.2, 2.2).
849
+ metric : str, optional
850
+ The metric to plot. Options: "LCOP", "ROI", "NPV", "PBT"/"PAYBACK"/
851
+ "PAYBACK_TIME", "IRR". Default is "LCOP".
852
+ label : str, optional
853
+ Custom y-axis label. If None, a default label is generated based
854
+ on the metric.
855
+ additional_capex : bool, optional
856
+ Whether to include additional CAPEX in ROI/payback calculations.
857
+ Default is False.
858
+ ax : matplotlib.axes.Axes, optional
859
+ Existing axes to plot on. If None, a new figure is created.
860
+ show : bool, optional
861
+ Whether to display the plot. Default is True.
862
+ Returns
863
+ -------
864
+ matplotlib.axes.Axes
865
+ The axes object containing the sensitivity plot.
866
+ Raises
867
+ ------
868
+ ValueError
869
+ If the parameter is unrecognized, ambiguous across plants, or
870
+ if the metric is unsupported.
871
+ """
872
+ # Normalize plants input
873
+ if not isinstance(plants, (list, tuple)):
874
+ plants = [plants]
875
+
876
+ metric = metric.upper()
877
+
878
+ # Default y-axis labels if not provided
879
+ if label is None:
880
+ if metric == "LCOP":
881
+ label = (
882
+ r"LCOH / [\$$\cdot$kg$^{-1}_\mathrm{H_2}$]"
883
+ )
884
+ elif metric == "ROI":
885
+ label = r"Return on investment / [\%]"
886
+ elif metric == "NPV":
887
+ label = r"Net present value / [\$]"
888
+ elif metric in ("PBT", "PAYBACK", "PAYBACK_TIME"):
889
+ label = "Payback time / [years]"
890
+ elif metric == "IRR":
891
+ label = "Internal rate of return / [-]"
892
+ else:
893
+ label = metric
894
+
895
+ # Color cycle for multiple plants
896
+ line_colors = cycle(plt.cm.Set2.colors)
897
+
898
+ # True top-level scalar/factor keys
899
+ top_level_keys = [
900
+ "fixed_capital",
901
+ "fixed_opex",
902
+ "project_lifetime",
903
+ "interest_rate",
904
+ "operator_hourly_rate",
905
+ ]
906
+
907
+ # --- Nested price keys across ALL plants ---
908
+ var_opex_keys_all = set(
909
+ f"variable_opex_inputs.{k}"
910
+ for plant in plants
911
+ for k in plant.variable_opex_inputs
912
+ )
913
+
914
+ product_keys_all = set(
915
+ f"plant_products.{k}"
916
+ for plant in plants
917
+ for k in plant.plant_products
918
+ )
919
+
920
+ byproduct_keys_all = set()
921
+ for plant in plants:
922
+ prod_keys = list(plant.plant_products.keys())
923
+ for k in prod_keys[1:]:
924
+ byproduct_keys_all.add(f"plant_products.{k}")
925
+
926
+ if metric == "LCOP":
927
+ nested_price_keys_all = var_opex_keys_all.union(
928
+ byproduct_keys_all
929
+ )
930
+ else:
931
+ nested_price_keys_all = var_opex_keys_all.union(
932
+ product_keys_all
933
+ )
934
+
935
+ valid_parameters = set(top_level_keys).union(
936
+ nested_price_keys_all
937
+ )
938
+
939
+ # --- Allow shorthand input like "co2_tax" instead of full path ---
940
+ short_to_full = {}
941
+ for plant in plants:
942
+ for k in plant.variable_opex_inputs:
943
+ full = f"variable_opex_inputs.{k}"
944
+ if (
945
+ k in short_to_full
946
+ and short_to_full[k] != full
947
+ ):
948
+ raise ValueError(
949
+ f"Ambiguous shorthand '{k}' across plants.\n"
950
+ f"Seen both '{short_to_full[k]}' and '{full}'. \n"
951
+ f"Please use full path."
952
+ )
953
+ short_to_full[k] = full
954
+
955
+ for k in plant.plant_products:
956
+ full = f"plant_products.{k}"
957
+ if (
958
+ k in short_to_full
959
+ and short_to_full[k] != full
960
+ ):
961
+ raise ValueError(
962
+ f"Ambiguous shorthand '{k}' across plants.\n"
963
+ f"Seen both '{short_to_full[k]}' and '{full}'. \n"
964
+ f"Please use full path."
965
+ )
966
+ short_to_full[k] = full
967
+
968
+ parameter = short_to_full.get(parameter, parameter)
969
+
970
+ if parameter not in valid_parameters:
971
+ raise ValueError(
972
+ f"Unrecognized parameter: {parameter}"
973
+ )
974
+
975
+ pct_changes = np.linspace(
976
+ -plus_minus_value, plus_minus_value, n_points
977
+ )
978
+ pct_axis = pct_changes * 100
979
+
980
+ label_map = {
981
+ "fixed_capital": "Fixed CAPEX",
982
+ "fixed_opex": "Fixed OPEX",
983
+ "project_lifetime": "Project lifetime",
984
+ "interest_rate": "Interest rate",
985
+ "operator_hourly_rate": "Operator hourly rate",
986
+ }
987
+
988
+ for plant in plants:
989
+ for var in plant.variable_opex_inputs:
990
+ key = f"variable_opex_inputs.{var}"
991
+ label_map[key] = f"{make_label(var)} price"
992
+ for prod in plant.plant_products:
993
+ key = f"plant_products.{prod}"
994
+ label_map[key] = f"{make_label(prod)} price"
995
+
996
+ label_raw = label_map.get(
997
+ parameter,
998
+ parameter.replace("variable_opex_inputs.", "")
999
+ .replace("plant_products.", "")
1000
+ .replace(".price", ""),
1001
+ )
1002
+ label_clean = make_label(label_raw)
1003
+ x_label = label_clean + r" / [$\pm$ \%]"
1004
+
1005
+ # --- Ax/fig handling ---
1006
+ created_fig = None
1007
+ if ax is None:
1008
+ created_fig, ax = plt.subplots(figsize=figsize)
1009
+
1010
+ # Loop over plants and plot each sensitivity curve
1011
+ for i, plant in enumerate(plants):
1012
+ var_opex_keys = set(
1013
+ f"variable_opex_inputs.{k}"
1014
+ for k in plant.variable_opex_inputs
1015
+ )
1016
+
1017
+ prod_key_list = list(plant.plant_products.keys())
1018
+ all_prod_keys = set(
1019
+ f"plant_products.{k}" for k in prod_key_list
1020
+ )
1021
+ byprod_keys = set(
1022
+ f"plant_products.{k}" for k in prod_key_list[1:]
1023
+ )
1024
+
1025
+ if metric == "LCOP":
1026
+ nested_price_keys = var_opex_keys.union(
1027
+ byprod_keys
1028
+ )
1029
+ else:
1030
+ nested_price_keys = var_opex_keys.union(
1031
+ all_prod_keys
1032
+ )
1033
+
1034
+ plant_valid_params = set(top_level_keys).union(
1035
+ nested_price_keys
1036
+ )
1037
+
1038
+ # Baseline metric
1039
+ if metric == "LCOP":
1040
+ if not hasattr(plant, "levelized_cost"):
1041
+ plant.calculate_levelized_cost()
1042
+ base_value = plant.levelized_cost
1043
+ elif metric == "ROI":
1044
+ plant.calculate_levelized_cost()
1045
+ plant.calculate_roi(
1046
+ additional_capex=additional_capex
1047
+ )
1048
+ base_value = plant.roi
1049
+ elif metric == "NPV":
1050
+ plant.calculate_levelized_cost()
1051
+ base_value = plant.calculate_npv()
1052
+ elif metric in ("PBT", "PAYBACK", "PAYBACK_TIME"):
1053
+ plant.calculate_levelized_cost()
1054
+ base_value = plant.calculate_payback_time(
1055
+ additional_capex=additional_capex
1056
+ )
1057
+ elif metric == "IRR":
1058
+ plant.calculate_levelized_cost()
1059
+ plant.calculate_irr()
1060
+ base_value = plant.irr
1061
+ else:
1062
+ raise ValueError(
1063
+ f"Unsupported metric '{metric}'."
1064
+ )
1065
+
1066
+ color = next(line_colors)
1067
+ plant_label = getattr(plant, "name", f"Plant {i+1}")
1068
+
1069
+ if parameter not in plant_valid_params:
1070
+ metric_values = np.full_like(
1071
+ pct_axis, fill_value=base_value, dtype=float
1072
+ )
1073
+ else:
1074
+ if parameter in ["fixed_capital", "fixed_opex"]:
1075
+ original_value = 1.0
1076
+ else:
1077
+ original_value = get_original_value(
1078
+ plant, parameter
1079
+ )
1080
+
1081
+ param_values = original_value * (
1082
+ 1 + pct_changes
1083
+ )
1084
+
1085
+ metric_values = [
1086
+ update_and_evaluate(
1087
+ plant,
1088
+ parameter,
1089
+ v,
1090
+ list(nested_price_keys),
1091
+ metric=metric,
1092
+ additional_capex=additional_capex,
1093
+ )
1094
+ for v in param_values
1095
+ ]
1096
+
1097
+ ax.plot(
1098
+ pct_axis,
1099
+ metric_values,
1100
+ linewidth=1,
1101
+ color=color,
1102
+ label=plant_label,
1103
+ linestyle="-",
1104
+ )
1105
+
1106
+ ax.set_xlabel(x_label)
1107
+ ax.set_ylabel(label)
1108
+ ax.legend(loc="best")
1109
+
1110
+ # Only tighten/show if we created the figure
1111
+ # (prevents messing up your dashboard layout)
1112
+ if created_fig is not None:
1113
+ created_fig.tight_layout()
1114
+ if show:
1115
+ plt.show()
1116
+ else:
1117
+ if show:
1118
+ ax.figure.canvas.draw_idle()
1119
+
1120
+ return ax
1121
+
1122
+
1123
+ def tornado_plot(
1124
+ plant,
1125
+ plus_minus_value,
1126
+ metric="LCOP",
1127
+ figsize=(3.4, 2.4),
1128
+ label=None,
1129
+ ax=None,
1130
+ show: bool = True,
1131
+ ):
1132
+ """
1133
+ Generate a tornado plot for sensitivity analysis of a plant economic model.
1134
+ A tornado plot visualizes the impact of variations in input parameters on a
1135
+ selected economic metric. Each horizontal bar shows the range of the metric
1136
+ value when a parameter is varied by a specified percentage above and below
1137
+ its baseline value.
1138
+ Parameters
1139
+ ----------
1140
+ plant : object
1141
+ A plant object with economic parameters
1142
+ and methods to calculate metrics.
1143
+ Expected attributes include:
1144
+ fixed_capital, fixed_opex, project_lifetime, interest_rate,
1145
+ operator_hourly_rate, variable_opex_inputs, and plant_products.
1146
+ plus_minus_value : float
1147
+ The fractional variation applied to parameters (e.g., 0.1 for ±10%).
1148
+ metric : str, optional
1149
+ The economic metric to analyze. Options are:
1150
+ - "LCOP": Levelized cost of product (default)
1151
+ - "ROI": Return on investment
1152
+ - "NPV": Net present value
1153
+ - "PBT"/"PAYBACK"/"PAYBACK_TIME": Payback time in years
1154
+ - "IRR": Internal rate of return
1155
+ Default is "LCOP".
1156
+ figsize : tuple, optional
1157
+ Figure size as (width, height) in inches. Default is (3.4, 2.4).
1158
+ label : str, optional
1159
+ Custom x-axis label.
1160
+ If None, a default label is generated based on the metric.
1161
+ ax : matplotlib.axes.Axes, optional
1162
+ Existing matplotlib axes object to plot on.
1163
+ If None, a new figure is created.
1164
+ show : bool, optional
1165
+ If True and a new figure was created, displays the plot.
1166
+ Default is True.
1167
+ Returns
1168
+ -------
1169
+ matplotlib.axes.Axes
1170
+ The axes object containing the tornado plot.
1171
+ Notes
1172
+ -----
1173
+ - Parameters are sorted by their total effect (sensitivity) on the metric,
1174
+ with the largest effects at the top.
1175
+ - Blue bars represent the impact of decreasing a parameter;
1176
+ red bars represent increasing it.
1177
+ - A vertical dashed line indicates the baseline metric value.
1178
+ - The plot uses a label mapping for cleaner parameter names on the y-axis.
1179
+ Raises
1180
+ ------
1181
+ ValueError
1182
+ If an unsupported metric is specified.
1183
+ """
1184
+ metric = metric.upper()
1185
+
1186
+ # Default x-axis labels if not provided
1187
+ if label is None:
1188
+ if metric == "LCOP":
1189
+ label = (
1190
+ r"Levelized cost / [\$$\cdot$unit$^{-1}$]"
1191
+ )
1192
+ elif metric == "ROI":
1193
+ label = r"Return on investment / [\%]"
1194
+ elif metric == "NPV":
1195
+ label = r"Net present value / [\$]"
1196
+ elif metric in ("PBT", "PAYBACK", "PAYBACK_TIME"):
1197
+ label = "Payback time / [years]"
1198
+ elif metric == "IRR":
1199
+ label = "Internal rate of return / [-]"
1200
+ else:
1201
+ label = metric
1202
+
1203
+ top_level_keys = [
1204
+ "fixed_capital",
1205
+ "fixed_opex",
1206
+ "project_lifetime",
1207
+ "interest_rate",
1208
+ "operator_hourly_rate",
1209
+ ]
1210
+
1211
+ var_opex_price_keys = [
1212
+ f"variable_opex_inputs.{k}"
1213
+ for k in plant.variable_opex_inputs.keys()
1214
+ ]
1215
+ product_price_keys = [
1216
+ f"plant_products.{k}"
1217
+ for k in plant.plant_products.keys()
1218
+ ]
1219
+
1220
+ nested_price_keys = (
1221
+ var_opex_price_keys
1222
+ if metric == "LCOP"
1223
+ else (var_opex_price_keys + product_price_keys)
1224
+ )
1225
+ all_keys = top_level_keys + nested_price_keys
1226
+
1227
+ # --- Baseline value for the selected metric ---
1228
+ if metric == "LCOP":
1229
+ plant.calculate_levelized_cost()
1230
+ base_value = plant.levelized_cost
1231
+ elif metric == "ROI":
1232
+ plant.calculate_levelized_cost()
1233
+ plant.calculate_roi()
1234
+ base_value = plant.roi
1235
+ elif metric == "NPV":
1236
+ plant.calculate_levelized_cost()
1237
+ base_value = plant.calculate_npv()
1238
+ elif metric in ("PBT", "PAYBACK", "PAYBACK_TIME"):
1239
+ plant.calculate_levelized_cost()
1240
+ base_value = plant.calculate_payback_time()
1241
+ elif metric == "IRR":
1242
+ plant.calculate_levelized_cost()
1243
+ plant.calculate_irr()
1244
+ base_value = plant.irr
1245
+ else:
1246
+ raise ValueError(f"Unsupported metric '{metric}'.")
1247
+
1248
+ # --- Sensitivity analysis: low / high for each parameter ---
1249
+ sensitivity_results = {}
1250
+ for key in all_keys:
1251
+ if key in ["fixed_capital", "fixed_opex"]:
1252
+ low = 1 - plus_minus_value
1253
+ high = 1 + plus_minus_value
1254
+
1255
+ elif key == "operator_hourly_rate":
1256
+ current = getattr(
1257
+ plant, "operator_hourly_rate", None
1258
+ )
1259
+ if isinstance(current, dict):
1260
+ original = current.get("rate", 0.0)
1261
+ else:
1262
+ original = (
1263
+ 0.0
1264
+ if current is None
1265
+ else float(current)
1266
+ )
1267
+ low = original * (1 - plus_minus_value)
1268
+ high = original * (1 + plus_minus_value)
1269
+
1270
+ else:
1271
+ original = get_original_value(plant, key)
1272
+ low = original * (1 - plus_minus_value)
1273
+ high = original * (1 + plus_minus_value)
1274
+
1275
+ metric_low = update_and_evaluate(
1276
+ plant,
1277
+ key,
1278
+ low,
1279
+ nested_price_keys,
1280
+ metric=metric,
1281
+ )
1282
+ metric_high = update_and_evaluate(
1283
+ plant,
1284
+ key,
1285
+ high,
1286
+ nested_price_keys,
1287
+ metric=metric,
1288
+ )
1289
+
1290
+ sensitivity_results[key] = [metric_low, metric_high]
1291
+
1292
+ factors = list(sensitivity_results.keys())
1293
+ lows = np.array(
1294
+ [sensitivity_results[f][0] for f in factors],
1295
+ dtype=float,
1296
+ )
1297
+ highs = np.array(
1298
+ [sensitivity_results[f][1] for f in factors],
1299
+ dtype=float,
1300
+ )
1301
+ total_effects = np.abs(highs - lows)
1302
+
1303
+ sorted_indices = np.argsort(
1304
+ total_effects
1305
+ ) # small -> large (largest appears at top in barh)
1306
+ factors_sorted = [factors[i] for i in sorted_indices]
1307
+ lows_sorted = lows[sorted_indices]
1308
+ highs_sorted = highs[sorted_indices]
1309
+
1310
+ # --- Label mapping for pretty y-axis names ---
1311
+ label_map = {
1312
+ "fixed_capital": "Fixed CAPEX",
1313
+ "fixed_opex": "Fixed OPEX",
1314
+ "project_lifetime": "Project lifetime",
1315
+ "interest_rate": "Interest rate",
1316
+ "operator_hourly_rate": "Operator hourly rate",
1317
+ }
1318
+ for var in plant.variable_opex_inputs:
1319
+ label_map[f"variable_opex_inputs.{var}"] = (
1320
+ f"{make_label(var)} price"
1321
+ )
1322
+ for prod in plant.plant_products:
1323
+ label_map[f"plant_products.{prod}"] = (
1324
+ f"{make_label(prod)} price"
1325
+ )
1326
+
1327
+ labels_sorted = [
1328
+ label_map.get(f, make_label(f))
1329
+ for f in factors_sorted
1330
+ ]
1331
+ y_pos = np.arange(len(labels_sorted))
1332
+
1333
+ # --- Ax/fig handling ---
1334
+ created_fig = None
1335
+ if ax is None:
1336
+ created_fig, ax = plt.subplots(figsize=figsize)
1337
+
1338
+ # Colors (keep your original)
1339
+ colors_low = ["#87CEEB"] * len(labels_sorted) # blue
1340
+ colors_high = ["#FF9999"] * len(labels_sorted) # red
1341
+
1342
+ # --- Plot ---
1343
+ for i in range(len(y_pos)):
1344
+ low_val = lows_sorted[i]
1345
+ high_val = highs_sorted[i]
1346
+
1347
+ ax.barh(
1348
+ y_pos[i],
1349
+ abs(low_val - base_value),
1350
+ left=min(base_value, low_val),
1351
+ color=colors_low[i],
1352
+ edgecolor="black",
1353
+ label=(
1354
+ rf"-{int(plus_minus_value * 100)}\%"
1355
+ if i == 0
1356
+ else ""
1357
+ ),
1358
+ )
1359
+
1360
+ ax.barh(
1361
+ y_pos[i],
1362
+ abs(high_val - base_value),
1363
+ left=min(base_value, high_val),
1364
+ color=colors_high[i],
1365
+ edgecolor="black",
1366
+ label=(
1367
+ rf"+{int(plus_minus_value * 100)}\%"
1368
+ if i == 0
1369
+ else ""
1370
+ ),
1371
+ )
1372
+
1373
+ ax.axvline(
1374
+ x=base_value,
1375
+ color="black",
1376
+ linestyle="--",
1377
+ linewidth=0.75,
1378
+ )
1379
+ ax.set_yticks(y_pos)
1380
+ ax.set_yticklabels(labels_sorted)
1381
+
1382
+ # x-limits with padding
1383
+ x_all = np.concatenate(
1384
+ [
1385
+ lows_sorted,
1386
+ highs_sorted,
1387
+ np.atleast_1d(base_value),
1388
+ ]
1389
+ )
1390
+ xmin, xmax = float(x_all.min()), float(x_all.max())
1391
+
1392
+ if xmin == xmax:
1393
+ pad = 0.05 * (1.0 if xmax == 0 else abs(xmax))
1394
+ left_lim, right_lim = xmin - pad, xmax + pad
1395
+ else:
1396
+ span = xmax - xmin
1397
+ pad = 0.05 * span
1398
+ left_lim, right_lim = xmin - pad, xmax + pad
1399
+
1400
+ ax.set_xlim(left_lim, right_lim)
1401
+ ax.set_xlabel(label)
1402
+ ax.legend(loc="best")
1403
+
1404
+ # Only manage layout/show if we created the figure
1405
+ if created_fig is not None:
1406
+ created_fig.tight_layout()
1407
+ if show:
1408
+ plt.show()
1409
+ else:
1410
+ if show:
1411
+ ax.figure.canvas.draw_idle()
1412
+
1413
+ return ax
1414
+
1415
+
1416
+ def truncated_normal_samples(mean, std, low, high, size):
1417
+ """
1418
+ Generate random samples from a truncated normal distribution.
1419
+ Parameters
1420
+ ----------
1421
+ mean : float
1422
+ Mean of the normal distribution.
1423
+ std : float
1424
+ Standard deviation of the normal distribution.
1425
+ low : float
1426
+ Lower bound of the truncation interval.
1427
+ high : float
1428
+ Upper bound of the truncation interval.
1429
+ size : int
1430
+ Number of random samples to generate.
1431
+ Returns
1432
+ -------
1433
+ ndarray
1434
+ Array of random samples from the truncated normal distribution.
1435
+ If std is zero or close to zero, returns an array filled with the
1436
+ clipped mean value.
1437
+ Notes
1438
+ -----
1439
+ When std is 0 or very close to 0, the function returns a deterministic
1440
+ result (the mean clipped to the [low, high] interval) instead of sampling.
1441
+ """
1442
+ if std == 0 or np.isclose(std, 0):
1443
+ return np.full(size, np.clip(mean, low, high))
1444
+
1445
+ a, b = (low - mean) / std, (high - mean) / std
1446
+
1447
+ return truncnorm.rvs(
1448
+ a, b, loc=mean, scale=std, size=size
1449
+ )
1450
+
1451
+
1452
+ def get_sampling_params(
1453
+ props, default_min=-1, default_max=1
1454
+ ):
1455
+ """
1456
+ Extract sampling parameters from a properties dictionary.
1457
+
1458
+ This function retrieves statistical parameters for sampling from a given
1459
+ properties dictionary. If certain keys are not present, default values
1460
+ are used for the min and max bounds.
1461
+
1462
+ Args:
1463
+ props (dict): A dictionary containing sampling parameter definitions.
1464
+ Expected keys are:
1465
+ - "price" (optional): The mean value for sampling.
1466
+ Defaults to 0.
1467
+ - "std" (optional): The standard deviation for sampling.
1468
+ Defaults to 0.
1469
+ - "min" (optional): The minimum bound for sampling.
1470
+ Defaults to default_min.
1471
+ - "max" (optional): The maximum bound for sampling.
1472
+ Defaults to default_max.
1473
+ default_min (float, optional): The default minimum bound value.
1474
+ Defaults to -1.
1475
+ default_max (float, optional): The default maximum bound value.
1476
+ Defaults to 1.
1477
+
1478
+ Returns:
1479
+ tuple: A tuple containing four values in order:
1480
+ - mean (float): The mean value for sampling.
1481
+ - std (float): The standard deviation for sampling.
1482
+ - min_ (float): The minimum bound for sampling.
1483
+ - max_ (float): The maximum bound for sampling.
1484
+ """
1485
+ mean = props.get("price", 0)
1486
+ std = props.get("std", 0)
1487
+ min_ = props.get("min", default_min)
1488
+ max_ = props.get("max", default_max)
1489
+ return mean, std, min_, max_
1490
+
1491
+
1492
+ def monte_carlo(
1493
+ plant,
1494
+ num_samples: int = 1_000_000,
1495
+ batch_size: int = 1000,
1496
+ additional_capex: bool = False,
1497
+ ):
1498
+ """
1499
+ Perform Monte Carlo simulation on a plant's economic metrics.
1500
+ This function conducts a probabilistic analysis of a plant's
1501
+ economic performance by sampling from distributions of various
1502
+ input parameters and computing multiple
1503
+ economic metrics across the samples.
1504
+ Parameters
1505
+ ----------
1506
+ plant : Plant
1507
+ The plant object to analyze. Must have economic calculation methods and
1508
+ configuration attributes initialized.
1509
+ num_samples : int, optional
1510
+ Total number of Monte Carlo samples to generate. Default is 1,000,000.
1511
+ batch_size : int, optional
1512
+ Number of samples to process per batch. Default is 1,000.
1513
+ Reduces memory usage by processing samples in chunks.
1514
+ additional_capex : bool, optional
1515
+ Whether to include additional capital expenditure in ROI and PBT
1516
+ calculations. Default is False.
1517
+ Returns
1518
+ -------
1519
+ tuple[dict, dict]
1520
+ - mc_metrics : dict
1521
+ Dictionary containing arrays of computed metrics for all samples:
1522
+ - "LCOP" : np.ndarray
1523
+ Levelized cost of product for each sample.
1524
+ - "ROI" : np.ndarray
1525
+ Return on investment for each sample
1526
+ (only if product prices available).
1527
+ - "NPV" : np.ndarray
1528
+ Net present value for each sample
1529
+ (only if product prices available).
1530
+ - "PBT" : np.ndarray
1531
+ Payback time for each sample
1532
+ (only if product prices available).
1533
+ - mc_inputs : dict
1534
+ Dictionary containing the sampled input parameters for each sample:
1535
+ - "Fixed capital factor" : np.ndarray
1536
+ - "Fixed opex factor" : np.ndarray
1537
+ - "Operator hourly rate" : np.ndarray
1538
+ - "Project lifetime" : np.ndarray
1539
+ - "Interest rate" : np.ndarray
1540
+ - Variable opex price samples for each item
1541
+ - Product price samples for each product
1542
+ Notes
1543
+ -----
1544
+ - The plant object is deep copied to avoid modifying the original
1545
+ during sampling.
1546
+ - All input parameters are sampled from truncated normal distributions.
1547
+ - Economic metrics (ROI, NPV, PBT) are only computed if product prices
1548
+ are defined.
1549
+ - Results are stored in the original plant object as
1550
+ `monte_carlo_metrics` and `monte_carlo_inputs` attributes.
1551
+ """
1552
+ # Ensure plant is baseline-initialized
1553
+ plant.calculate_fixed_capital()
1554
+ plant.calculate_variable_opex()
1555
+ plant.calculate_fixed_opex()
1556
+ plant.calculate_cash_flow()
1557
+ plant.calculate_levelized_cost()
1558
+
1559
+ plant_copy = deepcopy(plant)
1560
+ num_batches = num_samples // batch_size
1561
+
1562
+ # ---- Allocate arrays for ALL metrics ----
1563
+ mc_metrics = {
1564
+ "LCOP": np.zeros(num_samples),
1565
+ "ROI": np.zeros(num_samples),
1566
+ "NPV": np.zeros(num_samples),
1567
+ "PBT": np.zeros(num_samples),
1568
+ }
1569
+
1570
+ # ---- Allocate all input distributions (same as before) ----
1571
+ op_cfg = plant.operator_hourly_rate
1572
+ op_mean = op_cfg.get("rate", 38.11)
1573
+ op_std = op_cfg.get("std", 20 / 2)
1574
+ op_min = op_cfg.get("min", 10)
1575
+ op_max = op_cfg.get("max", 100)
1576
+
1577
+ fixed_capitals = np.zeros(num_samples)
1578
+ fixed_opexs = np.zeros(num_samples)
1579
+ operator_hourlys = np.zeros(num_samples)
1580
+ project_lifetimes = np.zeros(num_samples)
1581
+ interests = np.zeros(num_samples)
1582
+
1583
+ variable_opex_price_samples = {
1584
+ item: np.zeros(num_samples)
1585
+ for item in plant.variable_opex_inputs
1586
+ }
1587
+
1588
+ # Product revenues only needed for ROI, NPV, PBT
1589
+ have_product_prices = all(
1590
+ "price" in props
1591
+ for props in plant.plant_products.values()
1592
+ )
1593
+
1594
+ product_price_samples = (
1595
+ {
1596
+ prod: np.zeros(num_samples)
1597
+ for prod in plant.plant_products
1598
+ }
1599
+ if have_product_prices
1600
+ else {}
1601
+ )
1602
+
1603
+ # ---- Sampling loop ----
1604
+ for b in tqdm(range(num_batches), desc="Monte Carlo"):
1605
+ start = b * batch_size
1606
+ end = start + batch_size
1607
+
1608
+ # ---- Sample inputs ----
1609
+ fixed_capitals[start:end] = (
1610
+ truncated_normal_samples(
1611
+ 1, 0.3, 0.25, 1.75, batch_size
1612
+ )
1613
+ )
1614
+ fixed_opexs[start:end] = truncated_normal_samples(
1615
+ 1, 0.3, 0.25, 1.75, batch_size
1616
+ )
1617
+ operator_hourlys[start:end] = (
1618
+ truncated_normal_samples(
1619
+ op_mean, op_std, op_min, op_max, batch_size
1620
+ )
1621
+ )
1622
+ project_lifetimes[start:end] = (
1623
+ truncated_normal_samples(
1624
+ plant.project_lifetime,
1625
+ 5,
1626
+ max(5, plant.project_lifetime - 2 * 5),
1627
+ plant.project_lifetime + 2 * 5,
1628
+ batch_size,
1629
+ )
1630
+ )
1631
+ interests[start:end] = truncated_normal_samples(
1632
+ plant.interest_rate,
1633
+ 0.03,
1634
+ max(0.02, plant.interest_rate - 2 * 0.03),
1635
+ plant.interest_rate + 2 * 0.03,
1636
+ batch_size,
1637
+ )
1638
+
1639
+ for (
1640
+ item,
1641
+ props,
1642
+ ) in plant.variable_opex_inputs.items():
1643
+ mean, std, min_, max_ = get_sampling_params(
1644
+ props
1645
+ )
1646
+ samples = truncated_normal_samples(
1647
+ mean, std, min_, max_, batch_size
1648
+ )
1649
+ variable_opex_price_samples[item][
1650
+ start:end
1651
+ ] = samples
1652
+
1653
+ if have_product_prices:
1654
+ for prod, props in plant.plant_products.items():
1655
+ mean, std, min_, max_ = get_sampling_params(
1656
+ props
1657
+ )
1658
+
1659
+ samples = truncated_normal_samples(
1660
+ mean, std, min_, max_, batch_size
1661
+ )
1662
+ product_price_samples[prod][
1663
+ start:end
1664
+ ] = samples
1665
+
1666
+ # ---- Apply sampled inputs ----
1667
+ plant_copy.operator_hourly_rate["rate"] = (
1668
+ operator_hourlys[start:end]
1669
+ )
1670
+ plant_copy.update_configuration(
1671
+ {
1672
+ "project_lifetime": project_lifetimes[
1673
+ start:end
1674
+ ],
1675
+ "interest_rate": interests[start:end],
1676
+ }
1677
+ )
1678
+
1679
+ for item in plant.variable_opex_inputs:
1680
+ plant_copy.variable_opex_inputs[item][
1681
+ "price"
1682
+ ] = variable_opex_price_samples[item][start:end]
1683
+
1684
+ if have_product_prices:
1685
+ for prod in plant.plant_products:
1686
+ plant_copy.plant_products[prod]["price"] = (
1687
+ product_price_samples[prod][start:end]
1688
+ )
1689
+
1690
+ # ---- Economic calculations ----
1691
+ plant_copy.calculate_fixed_capital(
1692
+ fc=fixed_capitals[start:end]
1693
+ )
1694
+ plant_copy.calculate_variable_opex()
1695
+ plant_copy.calculate_fixed_opex(
1696
+ fp=fixed_opexs[start:end]
1697
+ )
1698
+ plant_copy.calculate_cash_flow()
1699
+ plant_copy.calculate_levelized_cost()
1700
+
1701
+ # ---- Store LCOP always ----
1702
+ mc_metrics["LCOP"][
1703
+ start:end
1704
+ ] = plant_copy.levelized_cost
1705
+
1706
+ # ---- If revenue available, compute all other metrics ----
1707
+ if have_product_prices:
1708
+ mc_metrics["ROI"][start:end] = (
1709
+ plant_copy.calculate_roi(
1710
+ additional_capex=additional_capex
1711
+ )
1712
+ )
1713
+ mc_metrics["NPV"][
1714
+ start:end
1715
+ ] = plant_copy.calculate_npv()
1716
+ mc_metrics["PBT"][start:end] = (
1717
+ plant_copy.calculate_payback_time(
1718
+ additional_capex=additional_capex
1719
+ )
1720
+ )
1721
+
1722
+ # ---- Store all results on plant ----
1723
+ plant.monte_carlo_metrics = mc_metrics
1724
+ plant.monte_carlo_inputs = {
1725
+ "Fixed capital factor": fixed_capitals,
1726
+ "Fixed opex factor": fixed_opexs,
1727
+ "Operator hourly rate": operator_hourlys,
1728
+ "Project lifetime": project_lifetimes,
1729
+ "Interest rate": interests,
1730
+ **{
1731
+ f"{k} price": v
1732
+ for k, v in variable_opex_price_samples.items()
1733
+ },
1734
+ **{
1735
+ f"{k} product price": v
1736
+ for k, v in product_price_samples.items()
1737
+ },
1738
+ }
1739
+
1740
+ return mc_metrics, plant.monte_carlo_inputs
1741
+
1742
+
1743
+ def default_metric_label(metric: str) -> str:
1744
+ """
1745
+ Generate a default metric label for a given metric name.
1746
+
1747
+ Parameters
1748
+ ----------
1749
+ metric : str
1750
+ The name of the metric to generate a label for. Case-insensitive.
1751
+ Supported metrics: 'LCOP', 'ROI', 'NPV', 'PBT', 'IRR'.
1752
+
1753
+ Returns
1754
+ -------
1755
+ str
1756
+ A formatted label string for the metric,
1757
+ including units where applicable.
1758
+ - 'LCOP': Levelized cost with units [$/unit]
1759
+ - 'ROI': Return on investment with units [%]
1760
+ - 'NPV': Net present value with units [$]
1761
+ - 'PBT', 'PAYBACK', 'PAYBACK_TIME': Payback time with units [years]
1762
+ - 'IRR': Internal rate of return with units [-]
1763
+ - Any other metric: Returns the uppercase version of the input metric
1764
+
1765
+ Examples
1766
+ --------
1767
+ >>> default_metric_label('lcop')
1768
+ 'Levelized cost / [\\$$\\cdot$unit$^{-1}$]'
1769
+ >>> default_metric_label('roi')
1770
+ 'Return on investment / [\\%]'
1771
+ >>> default_metric_label('payback_time')
1772
+ 'Payback time / [years]'
1773
+ """
1774
+ metric = metric.upper()
1775
+ if metric == "LCOP":
1776
+ return r"Levelized cost / [\$$\cdot$unit$^{-1}$]"
1777
+ elif metric == "ROI":
1778
+ return r"Return on investment / [\%]"
1779
+ elif metric == "NPV":
1780
+ return r"Net present value / [\$]"
1781
+ elif metric in ("PBT", "PAYBACK", "PAYBACK_TIME"):
1782
+ return "Payback time / [years]"
1783
+ elif metric == "IRR":
1784
+ return "Internal rate of return / [-]"
1785
+ return metric
1786
+
1787
+
1788
+ def plot_monte_carlo(
1789
+ plant,
1790
+ metric: str = None,
1791
+ bins: int = 30,
1792
+ label: str | None = None,
1793
+ ax=None,
1794
+ show: bool = True,
1795
+ ):
1796
+ """
1797
+ Plot a histogram of Monte Carlo simulation results
1798
+ with a fitted normal distribution.
1799
+ Parameters
1800
+ ----------
1801
+ plant : Plant or array-like
1802
+ Either a Plant object with monte_carlo_metrics attribute,
1803
+ or a numpy array of metric values to plot.
1804
+ metric : str, optional
1805
+ The metric to plot. Must be a key in plant.monte_carlo_metrics if plant
1806
+ is a Plant object. Default is "LCOP". Case-insensitive.
1807
+ bins : int, optional
1808
+ Number of histogram bins. Default is 30.
1809
+ label : str or None, optional
1810
+ Label for the x-axis. If None, a default label is generated based on
1811
+ the metric name. Default is None.
1812
+ ax : matplotlib.axes.Axes, optional
1813
+ Matplotlib axes object to plot on. If None, a new figure and axes are
1814
+ created. Default is None.
1815
+ show : bool, optional
1816
+ Whether to display the plot using plt.show(). Only applies if a new
1817
+ figure is created. Default is True.
1818
+ Returns
1819
+ -------
1820
+ matplotlib.axes.Axes
1821
+ The axes object containing the plot.
1822
+ Raises
1823
+ ------
1824
+ ValueError
1825
+ If the specified metric is not found in plant.monte_carlo_metrics.
1826
+ Notes
1827
+ -----
1828
+ The normal distribution is fitted to the data using scipy.stats.norm.fit().
1829
+ The standard deviation is displayed in scientific notation with mantissa
1830
+ and exponent separated for readability.
1831
+ """
1832
+ # --- Accept both Plant and array ---
1833
+ if hasattr(plant, "monte_carlo_metrics"):
1834
+ if metric is None:
1835
+ metric = "LCOP"
1836
+ metric = metric.upper()
1837
+
1838
+ if metric not in plant.monte_carlo_metrics:
1839
+ available = ", ".join(
1840
+ plant.monte_carlo_metrics.keys()
1841
+ )
1842
+ raise ValueError(
1843
+ f"Metric '{metric}' not found."
1844
+ f" Available: {available}"
1845
+ )
1846
+
1847
+ values = plant.monte_carlo_metrics[metric]
1848
+ else:
1849
+ values = np.asarray(plant)
1850
+ if metric is None:
1851
+ metric = "LCOP"
1852
+
1853
+ if label is None:
1854
+ label = default_metric_label(metric)
1855
+
1856
+ mu, std = norm.fit(values)
1857
+
1858
+ created_fig = None
1859
+ if ax is None:
1860
+ created_fig, ax = plt.subplots()
1861
+
1862
+ hist_color = next(cycle(plt.cm.tab10.colors))
1863
+ line_color = next(cycle(plt.cm.tab10.colors))
1864
+
1865
+ ax.hist(
1866
+ values,
1867
+ bins=bins,
1868
+ density=True,
1869
+ color=hist_color,
1870
+ edgecolor="black",
1871
+ alpha=0.6,
1872
+ zorder=1,
1873
+ label="Samples",
1874
+ )
1875
+
1876
+ x = np.linspace(values.min(), values.max(), 1000)
1877
+ p = norm.pdf(x, mu, std)
1878
+
1879
+ std_exp = int(np.floor(np.log10(std)))
1880
+ std_mant = std / 10**std_exp
1881
+ ax.plot(
1882
+ x,
1883
+ p,
1884
+ color=line_color,
1885
+ zorder=2,
1886
+ label=(
1887
+ rf"$\mu$={mu:.3g}, "
1888
+ rf"$\sigma$={std_mant:.2f}$\times 10^{{{std_exp}}}$"
1889
+ ),
1890
+ )
1891
+
1892
+ ax.set_xlabel(label)
1893
+ ax.set_ylabel("Probability density")
1894
+ ax.legend(loc="best", fontsize="x-small")
1895
+
1896
+ if created_fig is not None and show:
1897
+ created_fig.tight_layout()
1898
+ plt.show()
1899
+
1900
+ return ax
1901
+
1902
+
1903
+ def plot_monte_carlo_inputs(
1904
+ plant,
1905
+ figsize=None,
1906
+ bins: int = 50,
1907
+ show: bool = True,
1908
+ ):
1909
+ """
1910
+ Plot histograms of Monte Carlo input parameters.
1911
+ Creates a grid of histograms visualizing the distribution of Monte Carlo
1912
+ input parameters. If the plant object has a monte_carlo_inputs attribute,
1913
+ it uses that; otherwise, treats the plant argument as a dictionary of
1914
+ inputs.
1915
+ Parameters
1916
+ ----------
1917
+ plant : object or dict
1918
+ A plant object with a monte_carlo_inputs attribute, or a dictionary
1919
+ where keys are parameter names and values are arrays of sampled values.
1920
+ figsize : tuple, optional
1921
+ Figure size as (width, height) in inches. If None, defaults to
1922
+ (3*5, n_rows*3) where n_rows is calculated based on the number of
1923
+ parameters.
1924
+ bins : int, default=50
1925
+ Number of bins to use for each histogram.
1926
+ show : bool, default=True
1927
+ If True, calls plt.tight_layout() and plt.show() to display the figure.
1928
+ Returns
1929
+ -------
1930
+ numpy.ndarray
1931
+ Flattened array of matplotlib Axes objects from the subplot grid.
1932
+ Notes
1933
+ -----
1934
+ - Histograms are plotted with density=True for normalized distributions.
1935
+ - Unused subplot axes are turned off.
1936
+ - Parameters are arranged in a grid with 3 columns.
1937
+ """
1938
+ if hasattr(plant, "monte_carlo_inputs"):
1939
+ inputs = plant.monte_carlo_inputs
1940
+ else:
1941
+ inputs = plant
1942
+
1943
+ n_params = len(inputs)
1944
+ n_cols = 3
1945
+ n_rows = (n_params + n_cols - 1) // n_cols
1946
+
1947
+ if figsize is None:
1948
+ figsize = (n_cols * 5, n_rows * 3)
1949
+
1950
+ fig, axes = plt.subplots(
1951
+ n_rows, n_cols, figsize=figsize
1952
+ )
1953
+ axes = axes.flatten()
1954
+
1955
+ hist_color = next(cycle(plt.cm.tab10.colors))
1956
+
1957
+ for idx, (label, arr) in enumerate(inputs.items()):
1958
+ ax = axes[idx]
1959
+ ax.hist(
1960
+ arr,
1961
+ bins=bins,
1962
+ density=True,
1963
+ color=hist_color,
1964
+ edgecolor="black",
1965
+ alpha=0.7,
1966
+ )
1967
+ ax.set_title(label, fontsize=9)
1968
+
1969
+ for i in range(n_params, len(axes)):
1970
+ axes[i].axis("off")
1971
+
1972
+ if show:
1973
+ fig.tight_layout()
1974
+ plt.show()
1975
+
1976
+ return axes
1977
+
1978
+
1979
+ def plot_multiple_monte_carlo(
1980
+ plants,
1981
+ metric="LCOP",
1982
+ bins=30,
1983
+ figsize=None,
1984
+ label=None,
1985
+ ax=None,
1986
+ show: bool = True,
1987
+ ):
1988
+ """
1989
+ Plot multiple Monte Carlo simulation results as overlaid histograms with
1990
+ fitted normal distributions.
1991
+ Parameters
1992
+ ----------
1993
+ plants : list
1994
+ List of plant objects containing monte_carlo_metrics data.
1995
+ metric : str, optional
1996
+ The metric to plot from monte_carlo_metrics (default: "LCOP").
1997
+ The string is converted to uppercase.
1998
+ bins : int, optional
1999
+ Number of histogram bins (default: 30).
2000
+ figsize : tuple, optional
2001
+ Figure size as (width, height).
2002
+ If None, uses matplotlib default (default: None).
2003
+ label : str, optional
2004
+ Label for the x-axis.
2005
+ If None, uses default_metric_label(metric) (default: None).
2006
+ ax : matplotlib.axes.Axes, optional
2007
+ Axes object to plot on.
2008
+ If None, creates a new figure and axes (default: None).
2009
+ show : bool, optional
2010
+ If True, displays the plot.
2011
+ If False, closes the figure (default: True).
2012
+ Only applies when ax is None (i.e., when a new figure is created).
2013
+ Returns
2014
+ -------
2015
+ matplotlib.axes.Axes
2016
+ The axes object containing the plot.
2017
+ Notes
2018
+ -----
2019
+ - Each plant's data is plotted as a semi-transparent
2020
+ histogram with black edges.
2021
+ - A fitted normal distribution curve is overlaid for each plant.
2022
+ - The legend displays plant names and
2023
+ distribution parameters (μ and σ).
2024
+ - Legend position and number of columns are
2025
+ automatically adjusted based on the number of items.
2026
+ - Only plants with the specified metric in their
2027
+ monte_carlo_metrics are plotted.
2028
+ Raises
2029
+ ------
2030
+ AttributeError
2031
+ If a plant object lacks the monte_carlo_metrics attribute or the
2032
+ specified metric is not present.
2033
+ """
2034
+ metric = metric.upper()
2035
+
2036
+ created_fig = None
2037
+ if ax is None:
2038
+ if figsize is None:
2039
+ created_fig, ax = plt.subplots()
2040
+ else:
2041
+ created_fig, ax = plt.subplots(figsize=figsize)
2042
+
2043
+ hist_colors = cycle(plt.cm.tab10.colors)
2044
+ line_colors = cycle(plt.cm.tab10.colors)
2045
+
2046
+ for plant in plants:
2047
+ if (
2048
+ hasattr(plant, "monte_carlo_metrics")
2049
+ and metric in plant.monte_carlo_metrics
2050
+ ):
2051
+ values = plant.monte_carlo_metrics[metric]
2052
+ hist_color = next(hist_colors)
2053
+ line_color = next(line_colors)
2054
+
2055
+ mu, std = norm.fit(values)
2056
+
2057
+ ax.hist(
2058
+ values,
2059
+ bins=bins,
2060
+ alpha=0.5,
2061
+ density=True,
2062
+ edgecolor="black",
2063
+ color=hist_color,
2064
+ zorder=1,
2065
+ label=plant.name,
2066
+ )
2067
+
2068
+ x = np.linspace(
2069
+ values.min(), values.max(), 1000
2070
+ )
2071
+ p = norm.pdf(x, mu, std)
2072
+
2073
+ std_exp = int(np.floor(np.log10(std)))
2074
+ std_mant = std / 10**std_exp
2075
+
2076
+ ax.plot(
2077
+ x,
2078
+ p,
2079
+ color=line_color,
2080
+ linewidth=1.2,
2081
+ zorder=2,
2082
+ linestyle="-",
2083
+ label=(
2084
+ rf"$\mu$={mu:.3g}, "
2085
+ rf"$\sigma$={std_mant:.2f}$\times 10^{{{std_exp}}}$"
2086
+ ),
2087
+ )
2088
+
2089
+ if label is None:
2090
+ label = default_metric_label(metric)
2091
+
2092
+ ax.set_xlabel(label)
2093
+ ax.set_ylabel("Probability density")
2094
+
2095
+ handles, labels_list = ax.get_legend_handles_labels()
2096
+ n_items = len(labels_list)
2097
+
2098
+ if n_items <= 4:
2099
+ ncol, loc, bbox = 1, "best", None
2100
+ elif n_items <= 6:
2101
+ ncol, loc, bbox = 3, "upper center", (0.5, 1.15)
2102
+ else:
2103
+ ncol, loc, bbox = 4, "upper center", (0.5, 1.20)
2104
+
2105
+ ax.legend(
2106
+ loc=loc,
2107
+ ncol=ncol,
2108
+ fontsize=4,
2109
+ frameon=True,
2110
+ facecolor="white",
2111
+ framealpha=0.6,
2112
+ fancybox=True,
2113
+ bbox_to_anchor=bbox,
2114
+ )
2115
+
2116
+ if created_fig is not None:
2117
+ if bbox:
2118
+ created_fig.tight_layout(rect=[0, 0, 1, 0.92])
2119
+ else:
2120
+ created_fig.tight_layout()
2121
+
2122
+ if show:
2123
+ plt.show()
2124
+ else:
2125
+ plt.close(created_fig)
2126
+
2127
+ return ax