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/__init__.py +24 -0
- openpytea/analysis.py +2127 -0
- openpytea/data/cepci_values.csv +36 -0
- openpytea/data/cost_correlations.csv +166 -0
- openpytea/equipment.py +432 -0
- openpytea/plant.py +1821 -0
- openpytea-1.2.0.dist-info/METADATA +300 -0
- openpytea-1.2.0.dist-info/RECORD +11 -0
- openpytea-1.2.0.dist-info/WHEEL +5 -0
- openpytea-1.2.0.dist-info/licenses/LICENSE +21 -0
- openpytea-1.2.0.dist-info/top_level.txt +1 -0
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
|