exerpy 0.0.2__py3-none-any.whl → 0.0.4__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.
- exerpy/__init__.py +2 -4
- exerpy/analyses.py +849 -304
- exerpy/components/__init__.py +3 -0
- exerpy/components/combustion/base.py +53 -35
- exerpy/components/component.py +8 -8
- exerpy/components/heat_exchanger/base.py +188 -121
- exerpy/components/heat_exchanger/condenser.py +98 -62
- exerpy/components/heat_exchanger/simple.py +237 -137
- exerpy/components/heat_exchanger/steam_generator.py +46 -41
- exerpy/components/helpers/cycle_closer.py +61 -34
- exerpy/components/helpers/power_bus.py +117 -0
- exerpy/components/nodes/deaerator.py +176 -58
- exerpy/components/nodes/drum.py +50 -39
- exerpy/components/nodes/flash_tank.py +218 -43
- exerpy/components/nodes/mixer.py +249 -69
- exerpy/components/nodes/splitter.py +173 -0
- exerpy/components/nodes/storage.py +130 -0
- exerpy/components/piping/valve.py +311 -115
- exerpy/components/power_machines/generator.py +105 -38
- exerpy/components/power_machines/motor.py +111 -39
- exerpy/components/turbomachinery/compressor.py +214 -68
- exerpy/components/turbomachinery/pump.py +215 -68
- exerpy/components/turbomachinery/turbine.py +182 -74
- exerpy/cost_estimation/__init__.py +65 -0
- exerpy/cost_estimation/turton.py +1260 -0
- exerpy/data/cost_correlations/cepci_index.json +135 -0
- exerpy/data/cost_correlations/component_mapping.json +450 -0
- exerpy/data/cost_correlations/material_factors.json +428 -0
- exerpy/data/cost_correlations/pressure_factors.json +206 -0
- exerpy/data/cost_correlations/turton2008.json +726 -0
- exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_tables.pdf +0 -0
- exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_theory.pdf +0 -0
- exerpy/functions.py +389 -264
- exerpy/parser/from_aspen/aspen_config.py +57 -48
- exerpy/parser/from_aspen/aspen_parser.py +373 -280
- exerpy/parser/from_ebsilon/__init__.py +2 -2
- exerpy/parser/from_ebsilon/check_ebs_path.py +15 -19
- exerpy/parser/from_ebsilon/ebsilon_config.py +328 -226
- exerpy/parser/from_ebsilon/ebsilon_functions.py +205 -38
- exerpy/parser/from_ebsilon/ebsilon_parser.py +392 -255
- exerpy/parser/from_ebsilon/utils.py +16 -11
- exerpy/parser/from_tespy/tespy_config.py +33 -1
- exerpy/parser/from_tespy/tespy_parser.py +151 -0
- {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/METADATA +43 -2
- exerpy-0.0.4.dist-info/RECORD +57 -0
- exerpy-0.0.2.dist-info/RECORD +0 -44
- {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/WHEEL +0 -0
- {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turton 2008 cost estimation module.
|
|
3
|
+
|
|
4
|
+
This module provides equipment cost estimation using correlations from:
|
|
5
|
+
Turton, R., Bailie, R. C., Whiting, W. B., & Shaeiwitz, J. A. (2008).
|
|
6
|
+
Analysis, Synthesis, and Design of Chemical Processes (3rd ed.). Prentice Hall.
|
|
7
|
+
|
|
8
|
+
Note: This module requires proprietary data files that are not distributed
|
|
9
|
+
with ExerPy due to copyright restrictions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from tabulate import tabulate
|
|
18
|
+
|
|
19
|
+
from ..components.helpers.cycle_closer import CycleCloser
|
|
20
|
+
from ..components.helpers.power_bus import PowerBus
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TurtonCostEstimator:
|
|
24
|
+
"""
|
|
25
|
+
Cost estimator using Turton 2008 correlations.
|
|
26
|
+
|
|
27
|
+
This class estimates equipment costs using correlations from Turton et al. (2008)
|
|
28
|
+
with CEPCI (Chemical Engineering Plant Cost Index) adjustment for inflation.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
exergoeconomic_analysis : ExergoeconomicAnalysis
|
|
33
|
+
Instance of ExergoeconomicAnalysis to estimate costs for.
|
|
34
|
+
|
|
35
|
+
Attributes
|
|
36
|
+
----------
|
|
37
|
+
execo : ExergoeconomicAnalysis
|
|
38
|
+
Reference to the exergoeconomic analysis instance.
|
|
39
|
+
connections : dict
|
|
40
|
+
Dictionary of all energy/material connections in the system.
|
|
41
|
+
components : dict
|
|
42
|
+
Dictionary of all components in the system.
|
|
43
|
+
currency : str
|
|
44
|
+
Currency symbol used in cost reporting.
|
|
45
|
+
|
|
46
|
+
Notes
|
|
47
|
+
-----
|
|
48
|
+
Requires the following data files in src/exerpy/data/cost_correlations/:
|
|
49
|
+
- turton2008.json: Equipment cost correlations (K1, K2, K3, B1, B2 coefficients)
|
|
50
|
+
- material_factors.json: Material factors (Fm) for different materials
|
|
51
|
+
- pressure_factors.json: Pressure factor coefficients (C1, C2, C3)
|
|
52
|
+
- cepci_index.json: Chemical Engineering Plant Cost Index values
|
|
53
|
+
- component_mapping.json: Mapping between ExerPy components and Turton equipment
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, exergoeconomic_analysis):
|
|
57
|
+
"""
|
|
58
|
+
Initialize the Turton cost estimator.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
exergoeconomic_analysis : ExergoeconomicAnalysis
|
|
63
|
+
Instance of ExergoeconomicAnalysis that has been initialized.
|
|
64
|
+
"""
|
|
65
|
+
self.execo = exergoeconomic_analysis
|
|
66
|
+
self.connections = exergoeconomic_analysis.connections
|
|
67
|
+
self.components = exergoeconomic_analysis.components
|
|
68
|
+
self.currency = exergoeconomic_analysis.currency
|
|
69
|
+
self._cost_data = None # Lazy-loaded cost correlation data
|
|
70
|
+
|
|
71
|
+
def _load_cost_data(self):
|
|
72
|
+
"""
|
|
73
|
+
Load cost correlation data from JSON files.
|
|
74
|
+
|
|
75
|
+
Loads the following data files from the data/cost_correlations directory:
|
|
76
|
+
- turton2008.json: Equipment cost correlations (K1, K2, K3, B1, B2 coefficients)
|
|
77
|
+
- material_factors.json: Material factors (Fm) for different materials
|
|
78
|
+
- pressure_factors.json: Pressure factor coefficients (C1, C2, C3)
|
|
79
|
+
- cepci_index.json: Chemical Engineering Plant Cost Index values
|
|
80
|
+
- component_mapping.json: Mapping between ExerPy components and Turton equipment
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
dict
|
|
85
|
+
Dictionary containing all loaded cost data.
|
|
86
|
+
|
|
87
|
+
Notes
|
|
88
|
+
-----
|
|
89
|
+
Data is lazy-loaded and cached in self._cost_data for subsequent calls.
|
|
90
|
+
"""
|
|
91
|
+
if self._cost_data is not None:
|
|
92
|
+
return self._cost_data
|
|
93
|
+
|
|
94
|
+
# Navigate from cost_estimation module to data directory
|
|
95
|
+
data_dir = os.path.join(os.path.dirname(__file__), "..", "data", "cost_correlations")
|
|
96
|
+
|
|
97
|
+
self._cost_data = {}
|
|
98
|
+
|
|
99
|
+
files = {
|
|
100
|
+
"turton2008": "turton2008.json",
|
|
101
|
+
"material_factors": "material_factors.json",
|
|
102
|
+
"pressure_factors": "pressure_factors.json",
|
|
103
|
+
"cepci_index": "cepci_index.json",
|
|
104
|
+
"component_mapping": "component_mapping.json",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for key, filename in files.items():
|
|
108
|
+
filepath = os.path.join(data_dir, filename)
|
|
109
|
+
if os.path.exists(filepath):
|
|
110
|
+
with open(filepath, "r") as f:
|
|
111
|
+
self._cost_data[key] = json.load(f)
|
|
112
|
+
else:
|
|
113
|
+
logging.warning(f"Cost data file not found: {filepath}")
|
|
114
|
+
self._cost_data[key] = {}
|
|
115
|
+
|
|
116
|
+
return self._cost_data
|
|
117
|
+
|
|
118
|
+
def _get_equipment_correlation(self, equipment_type):
|
|
119
|
+
"""
|
|
120
|
+
Get cost correlation data for a specific equipment type.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
equipment_type : str
|
|
125
|
+
Equipment type in format "category.type" (e.g., "pumps.centrifugal_single_stage").
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
dict or None
|
|
130
|
+
Cost correlation data dictionary containing K1, K2, K3, B1, B2, etc.
|
|
131
|
+
Returns None if equipment type is not found.
|
|
132
|
+
"""
|
|
133
|
+
data = self._load_cost_data()
|
|
134
|
+
turton = data.get("turton2008", {})
|
|
135
|
+
|
|
136
|
+
# Parse equipment type (e.g., "pumps.centrifugal_single_stage")
|
|
137
|
+
parts = equipment_type.split(".")
|
|
138
|
+
if len(parts) != 2:
|
|
139
|
+
logging.warning(f"Invalid equipment type format: {equipment_type}")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
category, eq_type = parts
|
|
143
|
+
if category in turton and eq_type in turton[category]:
|
|
144
|
+
return turton[category][eq_type]
|
|
145
|
+
else:
|
|
146
|
+
logging.warning(f"Equipment type not found: {equipment_type}")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def calculate_Cp0(self, equipment_type, size, component_name=None, component_type=None):
|
|
150
|
+
"""
|
|
151
|
+
Calculate purchased equipment cost at base conditions (Cp0).
|
|
152
|
+
|
|
153
|
+
Uses the Turton 2008 correlation:
|
|
154
|
+
log10(Cp0) = K1 + K2*log10(A) + K3*(log10(A))^2
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
equipment_type : str
|
|
159
|
+
Equipment type in format "category.type" (e.g., "pumps.centrifugal_single_stage").
|
|
160
|
+
size : float
|
|
161
|
+
Size/capacity parameter in the unit specified for the equipment type.
|
|
162
|
+
- Heat exchangers: area [m2]
|
|
163
|
+
- Pumps/Compressors/Turbines: power [kW]
|
|
164
|
+
- Vessels: volume [m3]
|
|
165
|
+
component_name : str, optional
|
|
166
|
+
Name/label of the component (for warning messages).
|
|
167
|
+
component_type : str, optional
|
|
168
|
+
Type/class of the component (for warning messages).
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
float
|
|
173
|
+
Purchased equipment cost in USD at base conditions (CEPCI = 397, year 2001).
|
|
174
|
+
|
|
175
|
+
Raises
|
|
176
|
+
------
|
|
177
|
+
ValueError
|
|
178
|
+
If equipment type is not found or size is not positive.
|
|
179
|
+
|
|
180
|
+
Notes
|
|
181
|
+
-----
|
|
182
|
+
If size is outside the valid range for the equipment type, a warning is logged
|
|
183
|
+
but the calculation proceeds. Extrapolation outside the valid range may be inaccurate.
|
|
184
|
+
"""
|
|
185
|
+
corr = self._get_equipment_correlation(equipment_type)
|
|
186
|
+
if corr is None:
|
|
187
|
+
raise ValueError(f"Equipment correlation not found for: {equipment_type}")
|
|
188
|
+
|
|
189
|
+
# Check size limits
|
|
190
|
+
A_min = corr.get("capacity_min", 0)
|
|
191
|
+
A_max = corr.get("capacity_max", float("inf"))
|
|
192
|
+
capacity_unit = corr.get("capacity_unit", "")
|
|
193
|
+
|
|
194
|
+
if size < A_min or size > A_max:
|
|
195
|
+
# Build component identifier for warning message
|
|
196
|
+
comp_id = ""
|
|
197
|
+
if component_name and component_type:
|
|
198
|
+
comp_id = f"Component '{component_name}' ({component_type}): "
|
|
199
|
+
elif component_name:
|
|
200
|
+
comp_id = f"Component '{component_name}': "
|
|
201
|
+
elif component_type:
|
|
202
|
+
comp_id = f"Component type '{component_type}': "
|
|
203
|
+
|
|
204
|
+
logging.warning(
|
|
205
|
+
f"{comp_id}Size {size:.4g} {capacity_unit} is outside the valid range "
|
|
206
|
+
f"[{A_min}, {A_max}] {capacity_unit} for '{equipment_type}'. "
|
|
207
|
+
f"Cost extrapolation may be inaccurate."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Ensure size > 0 for logarithm
|
|
211
|
+
if size <= 0:
|
|
212
|
+
raise ValueError(f"Size must be positive, got {size}")
|
|
213
|
+
|
|
214
|
+
K1 = corr["K1"]
|
|
215
|
+
K2 = corr["K2"]
|
|
216
|
+
K3 = corr["K3"]
|
|
217
|
+
|
|
218
|
+
log_A = np.log10(size)
|
|
219
|
+
log_Cp = K1 + K2 * log_A + K3 * log_A**2
|
|
220
|
+
|
|
221
|
+
return 10**log_Cp
|
|
222
|
+
|
|
223
|
+
def calculate_Fp(self, pressure_factor_type, pressure, diameter=None):
|
|
224
|
+
"""
|
|
225
|
+
Calculate pressure factor (Fp) for equipment cost correction.
|
|
226
|
+
|
|
227
|
+
For most equipment: log10(Fp) = C1 + C2*log10(P) + C3*(log10(P))^2
|
|
228
|
+
For vessels: Fp = [(P+1)*D / (2*S*E - 1.2*(P+1)) + CA] / t_min
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
pressure_factor_type : str
|
|
233
|
+
Type of pressure factor calculation. Options:
|
|
234
|
+
- "heat_exchanger": Shell-and-tube heat exchangers
|
|
235
|
+
- "pump": Pumps
|
|
236
|
+
- "fired_heater": Fired heaters
|
|
237
|
+
- "air_cooler": Air-cooled heat exchangers
|
|
238
|
+
- "vessel": Process vessels (requires diameter)
|
|
239
|
+
- "none": No pressure correction (returns 1.0)
|
|
240
|
+
pressure : float
|
|
241
|
+
Operating pressure in barg (gauge pressure).
|
|
242
|
+
diameter : float, optional
|
|
243
|
+
Vessel diameter in meters (required for vessel pressure factor).
|
|
244
|
+
|
|
245
|
+
Returns
|
|
246
|
+
-------
|
|
247
|
+
float
|
|
248
|
+
Pressure factor Fp (dimensionless, >= 1.0).
|
|
249
|
+
|
|
250
|
+
Notes
|
|
251
|
+
-----
|
|
252
|
+
For low pressures (typically < 5-10 barg), Fp = 1.0.
|
|
253
|
+
For vacuum operation (P < -0.5 barg), vessels use Fp = 1.25.
|
|
254
|
+
"""
|
|
255
|
+
data = self._load_cost_data()
|
|
256
|
+
pf_data = data.get("pressure_factors", {})
|
|
257
|
+
|
|
258
|
+
if pressure_factor_type == "none":
|
|
259
|
+
return 1.0
|
|
260
|
+
|
|
261
|
+
if pressure_factor_type not in pf_data:
|
|
262
|
+
logging.warning(f"Pressure factor type not found: {pressure_factor_type}. Using Fp = 1.0")
|
|
263
|
+
return 1.0
|
|
264
|
+
|
|
265
|
+
pf_info = pf_data[pressure_factor_type]
|
|
266
|
+
|
|
267
|
+
# Special handling for vessels
|
|
268
|
+
if pressure_factor_type == "vessel":
|
|
269
|
+
if diameter is None:
|
|
270
|
+
logging.warning("Vessel diameter not provided. Using Fp = 1.0")
|
|
271
|
+
return 1.0
|
|
272
|
+
|
|
273
|
+
# Check for vacuum operation
|
|
274
|
+
vacuum_data = pf_info.get("vacuum_factor", {})
|
|
275
|
+
if pressure < vacuum_data.get("P_threshold", -0.5):
|
|
276
|
+
return vacuum_data.get("Fp", 1.25)
|
|
277
|
+
|
|
278
|
+
# Vessel pressure factor calculation
|
|
279
|
+
params = pf_info.get("parameters", {})
|
|
280
|
+
S = params.get("S_carbon_steel", {}).get("value", 944) # bar
|
|
281
|
+
E = params.get("E", {}).get("value", 0.9)
|
|
282
|
+
CA = params.get("CA", {}).get("value", 0.00315) # m
|
|
283
|
+
t_min = params.get("t_min", {}).get("value", 0.0063) # m
|
|
284
|
+
|
|
285
|
+
# Calculate wall thickness
|
|
286
|
+
P_abs = pressure + 1 # Absolute pressure in barg -> approximately bar gauge
|
|
287
|
+
denominator = 2 * S * E - 1.2 * P_abs
|
|
288
|
+
if denominator <= 0:
|
|
289
|
+
logging.warning(f"Vessel wall thickness calculation invalid for P={pressure} barg. Using Fp = 1.0")
|
|
290
|
+
return 1.0
|
|
291
|
+
|
|
292
|
+
t_calc = (P_abs * diameter) / denominator + CA
|
|
293
|
+
Fp = t_calc / t_min
|
|
294
|
+
|
|
295
|
+
return max(1.0, Fp)
|
|
296
|
+
|
|
297
|
+
# Standard pressure factor calculation using polynomial
|
|
298
|
+
ranges = pf_info.get("ranges", [])
|
|
299
|
+
|
|
300
|
+
for rng in ranges:
|
|
301
|
+
P_min = rng.get("P_min", float("-inf"))
|
|
302
|
+
P_max = rng.get("P_max", float("inf"))
|
|
303
|
+
|
|
304
|
+
if P_min <= pressure <= P_max:
|
|
305
|
+
# Check for fixed Fp value
|
|
306
|
+
if "Fp_fixed" in rng:
|
|
307
|
+
return rng["Fp_fixed"]
|
|
308
|
+
|
|
309
|
+
C1 = rng.get("C1", 0)
|
|
310
|
+
C2 = rng.get("C2", 0)
|
|
311
|
+
C3 = rng.get("C3", 0)
|
|
312
|
+
|
|
313
|
+
# For very low pressures (C1=C2=C3=0), Fp = 1.0
|
|
314
|
+
if C1 == 0 and C2 == 0 and C3 == 0:
|
|
315
|
+
return 1.0
|
|
316
|
+
|
|
317
|
+
# Avoid log of zero or negative
|
|
318
|
+
if pressure <= 0:
|
|
319
|
+
return 1.0
|
|
320
|
+
|
|
321
|
+
log_P = np.log10(pressure)
|
|
322
|
+
log_Fp = C1 + C2 * log_P + C3 * log_P**2
|
|
323
|
+
return max(1.0, 10**log_Fp)
|
|
324
|
+
|
|
325
|
+
# If pressure outside all ranges
|
|
326
|
+
logging.warning(f"Pressure {pressure} barg outside valid range for {pressure_factor_type}. Using Fp = 1.0")
|
|
327
|
+
return 1.0
|
|
328
|
+
|
|
329
|
+
def calculate_Fm(self, equipment_category, material):
|
|
330
|
+
"""
|
|
331
|
+
Get material factor (Fm) for equipment cost correction.
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
equipment_category : str
|
|
336
|
+
Equipment category for material factor lookup. Options:
|
|
337
|
+
- "heat_exchangers": Shell/tube material combinations (e.g., "CS/CS", "CS/SS")
|
|
338
|
+
- "process_vessels": Vessel materials (e.g., "CS", "SS304", "SS316")
|
|
339
|
+
- "pumps": Pump materials
|
|
340
|
+
- "compressors": Compressor materials
|
|
341
|
+
- "trays": Distillation tray materials
|
|
342
|
+
- "packing": Tower packing materials
|
|
343
|
+
- "plate_heat_exchangers": Plate HX materials
|
|
344
|
+
- "reactors": Reactor materials
|
|
345
|
+
- "fired_heaters": Fired heater tube materials
|
|
346
|
+
material : str
|
|
347
|
+
Material specification (e.g., "CS", "SS304", "CS/SS").
|
|
348
|
+
|
|
349
|
+
Returns
|
|
350
|
+
-------
|
|
351
|
+
float
|
|
352
|
+
Material factor Fm (dimensionless, >= 1.0).
|
|
353
|
+
|
|
354
|
+
Notes
|
|
355
|
+
-----
|
|
356
|
+
Carbon steel (CS) is the base material with Fm = 1.0 for most equipment.
|
|
357
|
+
For heat exchangers, the format is "shell_material/tube_material".
|
|
358
|
+
"""
|
|
359
|
+
data = self._load_cost_data()
|
|
360
|
+
mf_data = data.get("material_factors", {})
|
|
361
|
+
|
|
362
|
+
if equipment_category not in mf_data:
|
|
363
|
+
logging.warning(f"Material factor category not found: {equipment_category}. Using Fm = 1.0")
|
|
364
|
+
return 1.0
|
|
365
|
+
|
|
366
|
+
category_data = mf_data[equipment_category]
|
|
367
|
+
|
|
368
|
+
# Skip metadata keys
|
|
369
|
+
if material.startswith("_"):
|
|
370
|
+
return 1.0
|
|
371
|
+
|
|
372
|
+
if material in category_data:
|
|
373
|
+
mat_info = category_data[material]
|
|
374
|
+
if isinstance(mat_info, dict):
|
|
375
|
+
return mat_info.get("Fm", 1.0)
|
|
376
|
+
else:
|
|
377
|
+
return mat_info
|
|
378
|
+
else:
|
|
379
|
+
logging.warning(f"Material '{material}' not found in category '{equipment_category}'. Using Fm = 1.0")
|
|
380
|
+
return 1.0
|
|
381
|
+
|
|
382
|
+
def calculate_CBM(
|
|
383
|
+
self,
|
|
384
|
+
equipment_type,
|
|
385
|
+
size,
|
|
386
|
+
pressure=0,
|
|
387
|
+
material="CS",
|
|
388
|
+
diameter=None,
|
|
389
|
+
cepci_year=2024,
|
|
390
|
+
regional_factor=1.0,
|
|
391
|
+
component_name=None,
|
|
392
|
+
component_type=None,
|
|
393
|
+
):
|
|
394
|
+
"""
|
|
395
|
+
Calculate bare module cost (CBM) for equipment.
|
|
396
|
+
|
|
397
|
+
CBM = Cp0 * (B1 + B2*Fp*Fm) * (CEPCI_year / CEPCI_base) * regional_factor
|
|
398
|
+
or
|
|
399
|
+
CBM = Cp0 * FBM * (CEPCI_year / CEPCI_base) * regional_factor (for fixed FBM)
|
|
400
|
+
|
|
401
|
+
Parameters
|
|
402
|
+
----------
|
|
403
|
+
equipment_type : str
|
|
404
|
+
Equipment type in format "category.type" (e.g., "pumps.centrifugal_single_stage").
|
|
405
|
+
size : float
|
|
406
|
+
Size/capacity parameter in the appropriate unit for the equipment.
|
|
407
|
+
pressure : float, optional
|
|
408
|
+
Operating pressure in barg (default: 0, atmospheric).
|
|
409
|
+
material : str, optional
|
|
410
|
+
Material of construction (default: "CS" for carbon steel).
|
|
411
|
+
diameter : float, optional
|
|
412
|
+
Vessel diameter in meters (required for vessel pressure factor).
|
|
413
|
+
cepci_year : int, optional
|
|
414
|
+
Year for CEPCI cost adjustment (default: 2024).
|
|
415
|
+
regional_factor : float, optional
|
|
416
|
+
Regional location factor relative to US Gulf Coast (default: 1.0).
|
|
417
|
+
component_name : str, optional
|
|
418
|
+
Name/label of the component (for warning messages).
|
|
419
|
+
component_type : str, optional
|
|
420
|
+
Type/class of the component (for warning messages).
|
|
421
|
+
|
|
422
|
+
Returns
|
|
423
|
+
-------
|
|
424
|
+
float
|
|
425
|
+
Bare module cost in USD at the specified year and location.
|
|
426
|
+
|
|
427
|
+
Notes
|
|
428
|
+
-----
|
|
429
|
+
The base costs from Turton 2008 are at CEPCI = 397 (year 2001).
|
|
430
|
+
"""
|
|
431
|
+
corr = self._get_equipment_correlation(equipment_type)
|
|
432
|
+
if corr is None:
|
|
433
|
+
raise ValueError(f"Equipment correlation not found for: {equipment_type}")
|
|
434
|
+
|
|
435
|
+
# Calculate base purchased cost
|
|
436
|
+
Cp0 = self.calculate_Cp0(equipment_type, size, component_name, component_type)
|
|
437
|
+
|
|
438
|
+
# Get CEPCI values
|
|
439
|
+
data = self._load_cost_data()
|
|
440
|
+
cepci_data = data.get("cepci_index", {})
|
|
441
|
+
cepci_base = cepci_data.get("_metadata", {}).get("base_cepci_for_turton", 397)
|
|
442
|
+
cepci_values = cepci_data.get("annual_values", {})
|
|
443
|
+
cepci_current = cepci_values.get(str(cepci_year), cepci_values.get("2024", 800))
|
|
444
|
+
|
|
445
|
+
cepci_factor = cepci_current / cepci_base
|
|
446
|
+
|
|
447
|
+
# Check for fixed FBM
|
|
448
|
+
if "FBM" in corr:
|
|
449
|
+
FBM = corr["FBM"]
|
|
450
|
+
CBM = Cp0 * FBM * cepci_factor * regional_factor
|
|
451
|
+
else:
|
|
452
|
+
# Calculate pressure and material factors
|
|
453
|
+
pf_type = corr.get("pressure_factor_type", "none")
|
|
454
|
+
Fp = self.calculate_Fp(pf_type, pressure, diameter)
|
|
455
|
+
|
|
456
|
+
# Determine material factor category from equipment type
|
|
457
|
+
category = equipment_type.split(".")[0]
|
|
458
|
+
mf_category_map = {
|
|
459
|
+
"heat_exchangers": "heat_exchangers",
|
|
460
|
+
"pumps": "pumps",
|
|
461
|
+
"compressors": "compressors",
|
|
462
|
+
"vessels": "process_vessels",
|
|
463
|
+
"towers": "process_vessels",
|
|
464
|
+
"reactors": "reactors",
|
|
465
|
+
"fired_heaters": "fired_heaters",
|
|
466
|
+
"evaporators": "heat_exchangers",
|
|
467
|
+
}
|
|
468
|
+
mf_category = mf_category_map.get(category, "process_vessels")
|
|
469
|
+
|
|
470
|
+
# For shell-and-tube heat exchangers, convert material format
|
|
471
|
+
# "CS" -> "CS/CS" (shell/tube format)
|
|
472
|
+
mat_for_lookup = material
|
|
473
|
+
if mf_category == "heat_exchangers" and "/" not in material:
|
|
474
|
+
mat_for_lookup = f"{material}/{material}"
|
|
475
|
+
|
|
476
|
+
Fm = self.calculate_Fm(mf_category, mat_for_lookup)
|
|
477
|
+
|
|
478
|
+
B1 = corr.get("B1", 0)
|
|
479
|
+
B2 = corr.get("B2", 0)
|
|
480
|
+
|
|
481
|
+
CBM = Cp0 * (B1 + B2 * Fp * Fm) * cepci_factor * regional_factor
|
|
482
|
+
|
|
483
|
+
return CBM
|
|
484
|
+
|
|
485
|
+
def _get_component_size(self, component, mapping_info):
|
|
486
|
+
"""
|
|
487
|
+
Extract size parameter from component based on mapping configuration.
|
|
488
|
+
|
|
489
|
+
Parameters
|
|
490
|
+
----------
|
|
491
|
+
component : Component
|
|
492
|
+
ExerPy component object.
|
|
493
|
+
mapping_info : dict
|
|
494
|
+
Component mapping information from component_mapping.json.
|
|
495
|
+
|
|
496
|
+
Returns
|
|
497
|
+
-------
|
|
498
|
+
float or None
|
|
499
|
+
Size parameter value, or None if not available.
|
|
500
|
+
"""
|
|
501
|
+
params = mapping_info.get("parameters", {})
|
|
502
|
+
size_info = params.get("size", {})
|
|
503
|
+
size_info.get("unit", "")
|
|
504
|
+
|
|
505
|
+
# Try to get the attribute from component
|
|
506
|
+
attr_name = size_info.get("attribute")
|
|
507
|
+
if attr_name and hasattr(component, attr_name):
|
|
508
|
+
value = getattr(component, attr_name)
|
|
509
|
+
if value is not None:
|
|
510
|
+
return abs(value)
|
|
511
|
+
|
|
512
|
+
# Fallback: try to calculate from exergy values or other component data
|
|
513
|
+
comp_class = component.__class__.__name__
|
|
514
|
+
category = mapping_info.get("category", "")
|
|
515
|
+
|
|
516
|
+
# For turbomachinery and power machines, use E_F or E_P
|
|
517
|
+
if category in ["turbomachinery", "power_machines"]:
|
|
518
|
+
# Power output components (turbines, generators)
|
|
519
|
+
if "Turbine" in comp_class or "Generator" in comp_class:
|
|
520
|
+
if hasattr(component, "E_P") and component.E_P is not None:
|
|
521
|
+
# E_P is in W, convert to kW for cost correlations
|
|
522
|
+
return abs(component.E_P) / 1000
|
|
523
|
+
# Power input components (pumps, compressors, motors)
|
|
524
|
+
elif "Pump" in comp_class or "Compressor" in comp_class or "Motor" in comp_class:
|
|
525
|
+
if hasattr(component, "E_F") and component.E_F is not None:
|
|
526
|
+
# E_F is in W, convert to kW for cost correlations
|
|
527
|
+
return abs(component.E_F) / 1000
|
|
528
|
+
|
|
529
|
+
# For heat exchangers, estimate area from heat duty
|
|
530
|
+
if category == "heat_exchanger":
|
|
531
|
+
# Try to calculate heat duty from connection data
|
|
532
|
+
Q = self._calculate_heat_duty(component)
|
|
533
|
+
if Q is not None and Q > 0:
|
|
534
|
+
# Estimate area from heat duty: A = Q / (U x LMTD)
|
|
535
|
+
# Use typical U values based on heat exchanger type
|
|
536
|
+
U = self._get_typical_U_value(component) # W/(m2-K)
|
|
537
|
+
LMTD = self._estimate_LMTD(component) # K
|
|
538
|
+
if U > 0 and LMTD > 0:
|
|
539
|
+
# Q is in kW, U in W/(m2-K), LMTD in K
|
|
540
|
+
# A = Q [kW] x 1000 / (U x LMTD) = m2
|
|
541
|
+
area = (Q * 1000) / (U * LMTD)
|
|
542
|
+
return area
|
|
543
|
+
else:
|
|
544
|
+
logging.warning(
|
|
545
|
+
f"Could not estimate area for {component.name}. "
|
|
546
|
+
f"Heat duty Q={Q:.1f} kW. Please provide area via custom_sizes."
|
|
547
|
+
)
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
# For fired equipment (combustion chambers), use heat duty
|
|
551
|
+
if category == "fired_equipment" and hasattr(component, "E_F") and component.E_F is not None:
|
|
552
|
+
# E_F is in W, convert to kW
|
|
553
|
+
return abs(component.E_F) / 1000
|
|
554
|
+
|
|
555
|
+
# For vessels (Drum, Deaerator, FlashTank, Storage), estimate volume from flow data
|
|
556
|
+
if category == "vessels":
|
|
557
|
+
volume = self._estimate_vessel_volume(component)
|
|
558
|
+
if volume is not None:
|
|
559
|
+
return volume
|
|
560
|
+
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
def _calculate_heat_duty(self, component):
|
|
564
|
+
"""
|
|
565
|
+
Calculate heat duty for a heat exchanger from connection data.
|
|
566
|
+
|
|
567
|
+
Parameters
|
|
568
|
+
----------
|
|
569
|
+
component : Component
|
|
570
|
+
Heat exchanger component.
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
float or None
|
|
575
|
+
Heat duty in kW, or None if cannot be calculated.
|
|
576
|
+
|
|
577
|
+
Notes
|
|
578
|
+
-----
|
|
579
|
+
ExerPy stores enthalpies in J/kg, so Q = m x dh gives W.
|
|
580
|
+
This method converts to kW for consistency with cost correlations.
|
|
581
|
+
"""
|
|
582
|
+
try:
|
|
583
|
+
# Heat exchangers have 2 inlets and 2 outlets
|
|
584
|
+
# Calculate Q from the hot side: Q = m x (h_in - h_out)
|
|
585
|
+
if hasattr(component, "inl") and hasattr(component, "outl"):
|
|
586
|
+
inl = component.inl
|
|
587
|
+
outl = component.outl
|
|
588
|
+
|
|
589
|
+
# Try hot side (index 0)
|
|
590
|
+
if 0 in inl and 0 in outl:
|
|
591
|
+
m_hot = inl[0].get("m", 0)
|
|
592
|
+
h_in_hot = inl[0].get("h", 0) # J/kg in ExerPy
|
|
593
|
+
h_out_hot = outl[0].get("h", 0) # J/kg
|
|
594
|
+
# Q = m [kg/s] x dh [J/kg] = W, convert to kW
|
|
595
|
+
Q_hot_kW = abs(m_hot * (h_in_hot - h_out_hot)) / 1000
|
|
596
|
+
if Q_hot_kW > 0:
|
|
597
|
+
return Q_hot_kW
|
|
598
|
+
|
|
599
|
+
# Try cold side (index 1)
|
|
600
|
+
if 1 in inl and 1 in outl:
|
|
601
|
+
m_cold = inl[1].get("m", 0)
|
|
602
|
+
h_in_cold = inl[1].get("h", 0) # J/kg
|
|
603
|
+
h_out_cold = outl[1].get("h", 0) # J/kg
|
|
604
|
+
# Q = m [kg/s] x dh [J/kg] = W, convert to kW
|
|
605
|
+
Q_cold_kW = abs(m_cold * (h_out_cold - h_in_cold)) / 1000
|
|
606
|
+
if Q_cold_kW > 0:
|
|
607
|
+
return Q_cold_kW
|
|
608
|
+
except Exception as e:
|
|
609
|
+
logging.debug(f"Could not calculate heat duty for {component.name}: {e}")
|
|
610
|
+
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
def _estimate_vessel_volume(self, component):
|
|
614
|
+
"""
|
|
615
|
+
Estimate vessel volume from flow data for Drum, Deaerator, FlashTank, etc.
|
|
616
|
+
|
|
617
|
+
Uses the volumetric flow rate and typical residence time to estimate volume.
|
|
618
|
+
For steam drums and similar vessels, the residence time is based on typical
|
|
619
|
+
industrial practice for liquid holdup.
|
|
620
|
+
|
|
621
|
+
Parameters
|
|
622
|
+
----------
|
|
623
|
+
component : Component
|
|
624
|
+
Vessel component (Drum, Deaerator, FlashTank, Storage).
|
|
625
|
+
|
|
626
|
+
Returns
|
|
627
|
+
-------
|
|
628
|
+
float or None
|
|
629
|
+
Estimated volume in m3, or None if cannot be calculated.
|
|
630
|
+
|
|
631
|
+
Notes
|
|
632
|
+
-----
|
|
633
|
+
The estimation uses:
|
|
634
|
+
- V = Q_vol * t_residence
|
|
635
|
+
- Q_vol is calculated from mass flow rate and density
|
|
636
|
+
- Density is estimated from pressure and temperature using water properties
|
|
637
|
+
- Typical residence times: 60s for drums, 300s for deaerators/storage
|
|
638
|
+
"""
|
|
639
|
+
try:
|
|
640
|
+
comp_class = component.__class__.__name__
|
|
641
|
+
|
|
642
|
+
# Get inlet/outlet data
|
|
643
|
+
if not hasattr(component, "inl") or not hasattr(component, "outl"):
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
# Find the liquid outlet (saturated liquid for drums)
|
|
647
|
+
# For Drum: outlet 0 is typically saturated liquid, outlet 1 is saturated vapor
|
|
648
|
+
m_liquid = 0
|
|
649
|
+
rho_liquid = None
|
|
650
|
+
|
|
651
|
+
# Try to get liquid flow from outlets
|
|
652
|
+
for idx in component.outl:
|
|
653
|
+
outlet = component.outl[idx]
|
|
654
|
+
m = outlet.get("m", 0)
|
|
655
|
+
T = outlet.get("T") # K
|
|
656
|
+
p = outlet.get("p") # Pa
|
|
657
|
+
|
|
658
|
+
if m > 0 and T is not None and p is not None:
|
|
659
|
+
# Estimate density using CoolProp if available, else use approximation
|
|
660
|
+
try:
|
|
661
|
+
from CoolProp.CoolProp import PropsSI
|
|
662
|
+
|
|
663
|
+
# Check if this is liquid (quality x < 0.5) or use saturated liquid density
|
|
664
|
+
x = outlet.get("x")
|
|
665
|
+
if x is not None and x < 0.5:
|
|
666
|
+
# This is the liquid outlet
|
|
667
|
+
rho = PropsSI("D", "T", T, "P", p, "Water")
|
|
668
|
+
m_liquid = max(m_liquid, m)
|
|
669
|
+
rho_liquid = rho
|
|
670
|
+
elif x is None:
|
|
671
|
+
# No quality info, check if subcooled (T < T_sat)
|
|
672
|
+
T_sat = PropsSI("T", "P", p, "Q", 0, "Water")
|
|
673
|
+
if T <= T_sat:
|
|
674
|
+
rho = PropsSI("D", "T", T, "P", p, "Water")
|
|
675
|
+
m_liquid = max(m_liquid, m)
|
|
676
|
+
rho_liquid = rho
|
|
677
|
+
except Exception:
|
|
678
|
+
# Fallback: use approximate water density at saturation
|
|
679
|
+
# Rough estimate: rho ≈ 1000 - 0.1*(T-273) for liquid water
|
|
680
|
+
if T < 500: # Likely liquid
|
|
681
|
+
rho_liquid = 900 # Approximate density kg/m3
|
|
682
|
+
m_liquid = max(m_liquid, m)
|
|
683
|
+
|
|
684
|
+
if m_liquid <= 0 or rho_liquid is None:
|
|
685
|
+
# Try inlets as fallback
|
|
686
|
+
for idx in component.inl:
|
|
687
|
+
inlet = component.inl[idx]
|
|
688
|
+
m = inlet.get("m", 0)
|
|
689
|
+
T = inlet.get("T")
|
|
690
|
+
p = inlet.get("p")
|
|
691
|
+
|
|
692
|
+
if m > 0 and T is not None and p is not None:
|
|
693
|
+
try:
|
|
694
|
+
from CoolProp.CoolProp import PropsSI
|
|
695
|
+
|
|
696
|
+
rho = PropsSI("D", "T", T, "P", p, "Water")
|
|
697
|
+
m_liquid = max(m_liquid, m)
|
|
698
|
+
rho_liquid = rho
|
|
699
|
+
except Exception:
|
|
700
|
+
if T < 500:
|
|
701
|
+
rho_liquid = 900
|
|
702
|
+
m_liquid = max(m_liquid, m)
|
|
703
|
+
|
|
704
|
+
if m_liquid <= 0 or rho_liquid is None:
|
|
705
|
+
logging.warning(
|
|
706
|
+
f"Could not estimate volume for vessel {component.name}. "
|
|
707
|
+
f"Please provide volume via custom_sizes parameter."
|
|
708
|
+
)
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
# Volumetric flow rate [m3/s]
|
|
712
|
+
Q_vol = m_liquid / rho_liquid
|
|
713
|
+
|
|
714
|
+
# Typical residence times based on vessel type
|
|
715
|
+
if "Drum" in comp_class:
|
|
716
|
+
t_residence = 60 # seconds - typical for steam drums
|
|
717
|
+
elif "Deaerator" in comp_class:
|
|
718
|
+
t_residence = 300 # seconds - longer for deaeration
|
|
719
|
+
elif "FlashTank" in comp_class:
|
|
720
|
+
t_residence = 120 # seconds
|
|
721
|
+
elif "Storage" in comp_class:
|
|
722
|
+
t_residence = 600 # seconds - storage tanks need more volume
|
|
723
|
+
else:
|
|
724
|
+
t_residence = 120 # default
|
|
725
|
+
|
|
726
|
+
# Volume estimate
|
|
727
|
+
volume = Q_vol * t_residence
|
|
728
|
+
|
|
729
|
+
# Apply a minimum practical volume (0.1 m3) and add margin factor
|
|
730
|
+
margin_factor = 1.5 # Safety margin for sizing
|
|
731
|
+
volume = max(0.1, volume * margin_factor)
|
|
732
|
+
|
|
733
|
+
logging.info(
|
|
734
|
+
f"Estimated volume for {component.name}: {volume:.2f} m3 "
|
|
735
|
+
f"(m={m_liquid:.2f} kg/s, rho={rho_liquid:.0f} kg/m3, t_res={t_residence}s)"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
return volume
|
|
739
|
+
|
|
740
|
+
except Exception as e:
|
|
741
|
+
logging.debug(f"Could not estimate vessel volume for {component.name}: {e}")
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
def _get_typical_U_value(self, component):
|
|
745
|
+
"""
|
|
746
|
+
Get overall heat transfer coefficient (U) for heat exchanger sizing.
|
|
747
|
+
|
|
748
|
+
Checks in order:
|
|
749
|
+
1. Custom U value provided by user via custom_U_values parameter
|
|
750
|
+
2. U value parsed from simulation data (if available)
|
|
751
|
+
3. Default typical U value based on heat exchanger type (with warning)
|
|
752
|
+
|
|
753
|
+
Parameters
|
|
754
|
+
----------
|
|
755
|
+
component : Component
|
|
756
|
+
Heat exchanger component.
|
|
757
|
+
|
|
758
|
+
Returns
|
|
759
|
+
-------
|
|
760
|
+
float
|
|
761
|
+
U value in W/(m2-K).
|
|
762
|
+
"""
|
|
763
|
+
comp_name = component.name
|
|
764
|
+
comp_class = component.__class__.__name__
|
|
765
|
+
|
|
766
|
+
# 1. Check for user-provided custom U value
|
|
767
|
+
if hasattr(self, "_custom_U_values") and comp_name in self._custom_U_values:
|
|
768
|
+
return self._custom_U_values[comp_name]
|
|
769
|
+
|
|
770
|
+
# 2. Check if U value is available in simulation data (e.g., from TESPy UA)
|
|
771
|
+
# Look for UA or U attribute in component or connection data
|
|
772
|
+
if hasattr(component, "UA") and component.UA is not None and component.UA > 0:
|
|
773
|
+
# If we have UA, we'd need area to get U, but we're trying to find area
|
|
774
|
+
# So this is circular - skip for now
|
|
775
|
+
pass
|
|
776
|
+
|
|
777
|
+
# 3. Use default typical U value and track for warning
|
|
778
|
+
default_U = self._get_default_U_value(component)
|
|
779
|
+
|
|
780
|
+
# Track that this component is using a default U value
|
|
781
|
+
if hasattr(self, "_hx_using_default_U"):
|
|
782
|
+
self._hx_using_default_U.append((comp_name, comp_class, default_U))
|
|
783
|
+
|
|
784
|
+
return default_U
|
|
785
|
+
|
|
786
|
+
def _get_default_U_value(self, component):
|
|
787
|
+
"""
|
|
788
|
+
Get default typical U value based on heat exchanger type and operating conditions.
|
|
789
|
+
|
|
790
|
+
Parameters
|
|
791
|
+
----------
|
|
792
|
+
component : Component
|
|
793
|
+
Heat exchanger component.
|
|
794
|
+
|
|
795
|
+
Returns
|
|
796
|
+
-------
|
|
797
|
+
float
|
|
798
|
+
Default U value in W/(m2-K).
|
|
799
|
+
"""
|
|
800
|
+
comp_class = component.__class__.__name__
|
|
801
|
+
|
|
802
|
+
# Check temperatures to determine service type
|
|
803
|
+
try:
|
|
804
|
+
if hasattr(component, "inl") and hasattr(component, "outl"):
|
|
805
|
+
component.inl.get(0, {}).get("T", 300)
|
|
806
|
+
T_cold_in = component.inl.get(1, {}).get("T", 300)
|
|
807
|
+
|
|
808
|
+
# Condenser (refrigerant condensing): higher U
|
|
809
|
+
if "Condenser" in comp_class:
|
|
810
|
+
return 1500 # W/(m2-K) - refrigerant condensing to water/air
|
|
811
|
+
|
|
812
|
+
# Evaporator: lower U due to boiling
|
|
813
|
+
if T_cold_in < 273.15: # Below 0 deg C, likely refrigeration
|
|
814
|
+
return 300 # W/(m2-K) - evaporator with refrigerant
|
|
815
|
+
|
|
816
|
+
# Steam generator / boiler
|
|
817
|
+
if "SteamGenerator" in comp_class:
|
|
818
|
+
return 1000 # W/(m2-K)
|
|
819
|
+
|
|
820
|
+
# General liquid-liquid
|
|
821
|
+
return 500 # W/(m2-K) - default shell-and-tube
|
|
822
|
+
except Exception:
|
|
823
|
+
pass
|
|
824
|
+
|
|
825
|
+
return 500 # Default value
|
|
826
|
+
|
|
827
|
+
def _estimate_LMTD(self, component):
|
|
828
|
+
"""
|
|
829
|
+
Estimate Log Mean Temperature Difference (LMTD) for heat exchanger.
|
|
830
|
+
|
|
831
|
+
Parameters
|
|
832
|
+
----------
|
|
833
|
+
component : Component
|
|
834
|
+
Heat exchanger component.
|
|
835
|
+
|
|
836
|
+
Returns
|
|
837
|
+
-------
|
|
838
|
+
float
|
|
839
|
+
Estimated LMTD in K.
|
|
840
|
+
"""
|
|
841
|
+
try:
|
|
842
|
+
if hasattr(component, "inl") and hasattr(component, "outl"):
|
|
843
|
+
inl = component.inl
|
|
844
|
+
outl = component.outl
|
|
845
|
+
|
|
846
|
+
if 0 in inl and 0 in outl and 1 in inl and 1 in outl:
|
|
847
|
+
T_hot_in = inl[0].get("T", 0)
|
|
848
|
+
T_hot_out = outl[0].get("T", 0)
|
|
849
|
+
T_cold_in = inl[1].get("T", 0)
|
|
850
|
+
T_cold_out = outl[1].get("T", 0)
|
|
851
|
+
|
|
852
|
+
# Assume counter-current flow
|
|
853
|
+
dT1 = T_hot_in - T_cold_out
|
|
854
|
+
dT2 = T_hot_out - T_cold_in
|
|
855
|
+
|
|
856
|
+
# Avoid division by zero or log of negative
|
|
857
|
+
if dT1 > 0 and dT2 > 0 and abs(dT1 - dT2) > 0.01:
|
|
858
|
+
LMTD = (dT1 - dT2) / np.log(dT1 / dT2)
|
|
859
|
+
return max(LMTD, 1.0) # Minimum 1 K
|
|
860
|
+
elif dT1 > 0 and dT2 > 0:
|
|
861
|
+
return (dT1 + dT2) / 2 # Arithmetic mean if nearly equal
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
return 10.0 # Default LMTD of 10 K
|
|
866
|
+
|
|
867
|
+
def _get_component_pressure(self, component, mapping_info):
|
|
868
|
+
"""
|
|
869
|
+
Extract pressure parameter from component based on mapping configuration.
|
|
870
|
+
|
|
871
|
+
Parameters
|
|
872
|
+
----------
|
|
873
|
+
component : Component
|
|
874
|
+
ExerPy component object.
|
|
875
|
+
mapping_info : dict
|
|
876
|
+
Component mapping information from component_mapping.json.
|
|
877
|
+
|
|
878
|
+
Returns
|
|
879
|
+
-------
|
|
880
|
+
float
|
|
881
|
+
Pressure in barg, or 0 if not available.
|
|
882
|
+
"""
|
|
883
|
+
params = mapping_info.get("parameters", {})
|
|
884
|
+
pressure_info = params.get("pressure")
|
|
885
|
+
|
|
886
|
+
if pressure_info is None:
|
|
887
|
+
return 0
|
|
888
|
+
|
|
889
|
+
attr_name = pressure_info.get("attribute")
|
|
890
|
+
if attr_name and hasattr(component, attr_name):
|
|
891
|
+
p_pa = getattr(component, attr_name)
|
|
892
|
+
# Convert Pa to barg (assuming Pa input)
|
|
893
|
+
return (p_pa - 101325) / 100000 # Pa to barg
|
|
894
|
+
|
|
895
|
+
# Try to get pressure from inlet/outlet connections
|
|
896
|
+
if hasattr(component, "inlets") and component.inlets:
|
|
897
|
+
for conn_name in component.inlets:
|
|
898
|
+
conn = self.connections.get(conn_name, {})
|
|
899
|
+
if "p" in conn:
|
|
900
|
+
# Assume pressure is in Pa
|
|
901
|
+
return (conn["p"] - 101325) / 100000
|
|
902
|
+
|
|
903
|
+
# Fallback: try to get pressure from inl dictionary (ExerPy component structure)
|
|
904
|
+
if hasattr(component, "inl") and component.inl:
|
|
905
|
+
for idx in component.inl:
|
|
906
|
+
inlet = component.inl[idx]
|
|
907
|
+
if "p" in inlet and inlet["p"] is not None:
|
|
908
|
+
# Assume pressure is in Pa
|
|
909
|
+
return (inlet["p"] - 101325) / 100000
|
|
910
|
+
|
|
911
|
+
return 0
|
|
912
|
+
|
|
913
|
+
def _detect_turbine_type(self, component, mapping_info):
|
|
914
|
+
"""
|
|
915
|
+
Automatically detect turbine equipment type based on working fluid and size.
|
|
916
|
+
|
|
917
|
+
Parameters
|
|
918
|
+
----------
|
|
919
|
+
component : Component
|
|
920
|
+
Turbine component object.
|
|
921
|
+
mapping_info : dict
|
|
922
|
+
Component mapping information from component_mapping.json.
|
|
923
|
+
|
|
924
|
+
Returns
|
|
925
|
+
-------
|
|
926
|
+
str
|
|
927
|
+
Equipment type string (e.g., 'drives.steam_turbine', 'turbines.axial_gas').
|
|
928
|
+
"""
|
|
929
|
+
auto_detect = mapping_info.get("auto_detect", {})
|
|
930
|
+
steam_fluids = auto_detect.get("steam_fluids", ["water", "Water", "HEOS::Water", "IF97::Water"])
|
|
931
|
+
steam_equipment = auto_detect.get("steam_equipment", "drives.steam_turbine")
|
|
932
|
+
gas_equipment = auto_detect.get("gas_equipment", "turbines.axial_gas")
|
|
933
|
+
large_gas_turbine_equipment = auto_detect.get("large_gas_turbine_equipment", "drives.gas_turbine")
|
|
934
|
+
large_gas_turbine_threshold = auto_detect.get("large_gas_turbine_threshold_kW", 4000)
|
|
935
|
+
liquid_expander_equipment = auto_detect.get("liquid_expander_equipment", "turbines.radial_gas_liquid_expander")
|
|
936
|
+
|
|
937
|
+
# Try to get fluid from inlet connection
|
|
938
|
+
fluid = None
|
|
939
|
+
inlet_phase = None
|
|
940
|
+
|
|
941
|
+
if hasattr(component, "inl") and component.inl:
|
|
942
|
+
inlet = component.inl.get(0, {})
|
|
943
|
+
fluid = inlet.get("fluid")
|
|
944
|
+
|
|
945
|
+
# Check if fluid info is in connection data from self.connections
|
|
946
|
+
if fluid is None and hasattr(component, "inlets") and component.inlets:
|
|
947
|
+
for conn_name in component.inlets:
|
|
948
|
+
conn = self.connections.get(conn_name, {})
|
|
949
|
+
fluid = conn.get("fluid")
|
|
950
|
+
# Also check for phase information
|
|
951
|
+
inlet_phase = conn.get("x") # vapor quality
|
|
952
|
+
if fluid:
|
|
953
|
+
break
|
|
954
|
+
|
|
955
|
+
# Get turbine power for size-based selection
|
|
956
|
+
power_kW = None
|
|
957
|
+
if hasattr(component, "E_P") and component.E_P is not None:
|
|
958
|
+
power_kW = abs(component.E_P) / 1000 # Convert W to kW
|
|
959
|
+
|
|
960
|
+
# Determine equipment type based on fluid
|
|
961
|
+
if fluid:
|
|
962
|
+
# Check if it's a steam/water fluid
|
|
963
|
+
is_steam = any(sf.lower() in str(fluid).lower() for sf in steam_fluids)
|
|
964
|
+
|
|
965
|
+
if is_steam:
|
|
966
|
+
logging.info(f"Turbine {component.name}: detected steam/water fluid '{fluid}' -> using {steam_equipment}")
|
|
967
|
+
return steam_equipment
|
|
968
|
+
else:
|
|
969
|
+
# Check if it's a liquid (x close to 0) or gas/two-phase
|
|
970
|
+
if inlet_phase is not None and inlet_phase < 0.1:
|
|
971
|
+
# Liquid expander
|
|
972
|
+
logging.info(
|
|
973
|
+
f"Turbine {component.name}: detected liquid phase (x={inlet_phase:.2f}) "
|
|
974
|
+
f"with fluid '{fluid}' -> using {liquid_expander_equipment}"
|
|
975
|
+
)
|
|
976
|
+
return liquid_expander_equipment
|
|
977
|
+
else:
|
|
978
|
+
# Gas turbine / expander - check size for large gas turbines
|
|
979
|
+
if power_kW is not None and power_kW > large_gas_turbine_threshold:
|
|
980
|
+
logging.info(
|
|
981
|
+
f"Turbine {component.name}: detected large gas turbine "
|
|
982
|
+
f"(P={power_kW:.0f} kW > {large_gas_turbine_threshold} kW) "
|
|
983
|
+
f"with fluid '{fluid}' -> using {large_gas_turbine_equipment}"
|
|
984
|
+
)
|
|
985
|
+
return large_gas_turbine_equipment
|
|
986
|
+
else:
|
|
987
|
+
logging.info(
|
|
988
|
+
f"Turbine {component.name}: detected gas/refrigerant fluid '{fluid}' "
|
|
989
|
+
f"-> using {gas_equipment}"
|
|
990
|
+
)
|
|
991
|
+
return gas_equipment
|
|
992
|
+
|
|
993
|
+
# Default to steam turbine if fluid cannot be determined
|
|
994
|
+
logging.warning(
|
|
995
|
+
f"Turbine {component.name}: could not detect working fluid, defaulting to {steam_equipment}. "
|
|
996
|
+
f"Use custom_mappings to specify equipment type if this is incorrect."
|
|
997
|
+
)
|
|
998
|
+
return mapping_info.get("default", steam_equipment)
|
|
999
|
+
|
|
1000
|
+
def estimate_costs(
|
|
1001
|
+
self,
|
|
1002
|
+
cepci_year=2024,
|
|
1003
|
+
regional_factor=1.0,
|
|
1004
|
+
operating_hours=8000,
|
|
1005
|
+
equipment_lifetime=20,
|
|
1006
|
+
maintenance_factor=0.06,
|
|
1007
|
+
interest_rate=0.10,
|
|
1008
|
+
escalation_rate=0.03,
|
|
1009
|
+
default_material="CS",
|
|
1010
|
+
custom_mappings=None,
|
|
1011
|
+
custom_sizes=None,
|
|
1012
|
+
custom_U_values=None,
|
|
1013
|
+
):
|
|
1014
|
+
"""
|
|
1015
|
+
Estimate purchased equipment costs for all components in the system.
|
|
1016
|
+
|
|
1017
|
+
Uses Turton 2008 correlations to estimate bare module costs based on
|
|
1018
|
+
component parameters extracted from the exergy analysis.
|
|
1019
|
+
|
|
1020
|
+
Parameters
|
|
1021
|
+
----------
|
|
1022
|
+
cepci_year : int, optional
|
|
1023
|
+
Year for CEPCI cost adjustment (default: 2024).
|
|
1024
|
+
regional_factor : float, optional
|
|
1025
|
+
Regional location factor relative to US Gulf Coast (default: 1.0).
|
|
1026
|
+
operating_hours : float, optional
|
|
1027
|
+
Annual operating hours for converting capital to hourly cost (default: 8000).
|
|
1028
|
+
equipment_lifetime : float, optional
|
|
1029
|
+
Equipment lifetime in years for annualization (default: 20).
|
|
1030
|
+
maintenance_factor : float, optional
|
|
1031
|
+
Annual maintenance cost as fraction of capital cost (default: 0.06).
|
|
1032
|
+
interest_rate : float, optional
|
|
1033
|
+
Effective interest rate for capital recovery factor (default: 0.10 = 10%).
|
|
1034
|
+
escalation_rate : float, optional
|
|
1035
|
+
Nominal cost escalation rate for O&M costs (default: 0.03 = 3%).
|
|
1036
|
+
default_material : str, optional
|
|
1037
|
+
Default material of construction (default: "CS" for carbon steel).
|
|
1038
|
+
custom_mappings : dict, optional
|
|
1039
|
+
Custom equipment type mappings for specific components.
|
|
1040
|
+
Format: {"component_name": "category.equipment_type"}
|
|
1041
|
+
custom_sizes : dict, optional
|
|
1042
|
+
Custom size parameters for specific components.
|
|
1043
|
+
Format: {"component_name": size_value}
|
|
1044
|
+
custom_U_values : dict, optional
|
|
1045
|
+
Custom heat transfer coefficients (U values) for heat exchangers in W/(m2-K).
|
|
1046
|
+
Format: {"component_name": U_value}
|
|
1047
|
+
If not provided, default values are used and a warning is issued.
|
|
1048
|
+
|
|
1049
|
+
Returns
|
|
1050
|
+
-------
|
|
1051
|
+
dict
|
|
1052
|
+
Dictionary with component costs in the format required by run():
|
|
1053
|
+
{"component_name_Z": cost_in_currency_per_hour, ...}
|
|
1054
|
+
|
|
1055
|
+
Notes
|
|
1056
|
+
-----
|
|
1057
|
+
The returned costs are annualized capital costs plus levelized O&M,
|
|
1058
|
+
expressed in currency/hour for direct use with run().
|
|
1059
|
+
|
|
1060
|
+
The annualization formula used is:
|
|
1061
|
+
Z [currency/h] = (CBM * CRF + OMC_first_year * CELF) / operating_hours
|
|
1062
|
+
|
|
1063
|
+
Where:
|
|
1064
|
+
- CRF (Capital Recovery Factor) = i * (1+i)^n / ((1+i)^n - 1)
|
|
1065
|
+
- CELF (Cost Escalation Levelization Factor) = ((1 - k^n) / (1 - k)) * CRF
|
|
1066
|
+
- k = (1 + r_n) / (1 + i)
|
|
1067
|
+
- OMC_first_year = CBM * maintenance_factor
|
|
1068
|
+
"""
|
|
1069
|
+
data = self._load_cost_data()
|
|
1070
|
+
mapping_data = data.get("component_mapping", {}).get("components", {})
|
|
1071
|
+
|
|
1072
|
+
custom_mappings = custom_mappings or {}
|
|
1073
|
+
custom_sizes = custom_sizes or {}
|
|
1074
|
+
custom_U_values = custom_U_values or {}
|
|
1075
|
+
|
|
1076
|
+
# Store custom U values for use in _get_typical_U_value
|
|
1077
|
+
self._custom_U_values = custom_U_values
|
|
1078
|
+
self._hx_using_default_U = [] # Track heat exchangers using default U values
|
|
1079
|
+
|
|
1080
|
+
# Economic factors
|
|
1081
|
+
i = interest_rate
|
|
1082
|
+
r_n = escalation_rate
|
|
1083
|
+
n = equipment_lifetime
|
|
1084
|
+
|
|
1085
|
+
# Capital Recovery Factor (CRF)
|
|
1086
|
+
CRF = (i * (1 + i) ** n) / ((1 + i) ** n - 1)
|
|
1087
|
+
|
|
1088
|
+
# Cost Escalation Levelization Factor (CELF) for O&M costs
|
|
1089
|
+
k = (1 + r_n) / (1 + i)
|
|
1090
|
+
if abs(k - 1.0) < 1e-10:
|
|
1091
|
+
# Special case when escalation rate equals interest rate
|
|
1092
|
+
CELF = n * CRF
|
|
1093
|
+
else:
|
|
1094
|
+
CELF = ((1 - k**n) / (1 - k)) * CRF
|
|
1095
|
+
|
|
1096
|
+
estimated_costs = {}
|
|
1097
|
+
cost_details = {} # Store detailed cost breakdown for reporting
|
|
1098
|
+
|
|
1099
|
+
for comp_name, component in self.components.items():
|
|
1100
|
+
# Skip helper components
|
|
1101
|
+
if isinstance(component, CycleCloser | PowerBus):
|
|
1102
|
+
continue
|
|
1103
|
+
|
|
1104
|
+
comp_class = component.__class__.__name__
|
|
1105
|
+
|
|
1106
|
+
# Check if component has a cost correlation
|
|
1107
|
+
if comp_class not in mapping_data:
|
|
1108
|
+
logging.warning(f"No cost mapping for component type: {comp_class}")
|
|
1109
|
+
continue
|
|
1110
|
+
|
|
1111
|
+
mapping_info = mapping_data[comp_class]
|
|
1112
|
+
|
|
1113
|
+
# Handle components without cost correlations (e.g., Valve, Mixer, Splitter)
|
|
1114
|
+
# Set default cost of 0 for these components
|
|
1115
|
+
if not mapping_info.get("has_cost_correlation", False):
|
|
1116
|
+
logging.info(f"Component {comp_name} ({comp_class}) has no cost correlation - setting Z=0")
|
|
1117
|
+
estimated_costs[f"{comp_name}_Z"] = 0.0
|
|
1118
|
+
cost_details[comp_name] = {
|
|
1119
|
+
"equipment_type": "N/A (no correlation)",
|
|
1120
|
+
"size": 0,
|
|
1121
|
+
"size_unit": "-",
|
|
1122
|
+
"pressure_barg": 0,
|
|
1123
|
+
"material": "-",
|
|
1124
|
+
"CBM_USD": 0,
|
|
1125
|
+
"Z_hourly": 0,
|
|
1126
|
+
}
|
|
1127
|
+
continue
|
|
1128
|
+
|
|
1129
|
+
# Get equipment type (custom or default, with auto-detection for turbines)
|
|
1130
|
+
if comp_name in custom_mappings:
|
|
1131
|
+
equipment_type = custom_mappings[comp_name]
|
|
1132
|
+
elif comp_class == "Turbine" and "auto_detect" in mapping_info:
|
|
1133
|
+
# Auto-detect turbine type based on working fluid
|
|
1134
|
+
equipment_type = self._detect_turbine_type(component, mapping_info)
|
|
1135
|
+
else:
|
|
1136
|
+
default_type = mapping_info.get("default")
|
|
1137
|
+
if default_type is None:
|
|
1138
|
+
logging.warning(f"No default equipment type for {comp_class}")
|
|
1139
|
+
continue
|
|
1140
|
+
equipment_type = default_type
|
|
1141
|
+
|
|
1142
|
+
# Get size parameter
|
|
1143
|
+
if comp_name in custom_sizes:
|
|
1144
|
+
size = custom_sizes[comp_name]
|
|
1145
|
+
else:
|
|
1146
|
+
size = self._get_component_size(component, mapping_info)
|
|
1147
|
+
|
|
1148
|
+
if size is None or size <= 0:
|
|
1149
|
+
logging.warning(
|
|
1150
|
+
f"Could not determine size for component {comp_name} ({comp_class}). "
|
|
1151
|
+
f"Please provide size via custom_sizes parameter."
|
|
1152
|
+
)
|
|
1153
|
+
continue
|
|
1154
|
+
|
|
1155
|
+
# Get pressure
|
|
1156
|
+
pressure = self._get_component_pressure(component, mapping_info)
|
|
1157
|
+
|
|
1158
|
+
# Get diameter for vessels (approximate from volume if not available)
|
|
1159
|
+
diameter = None
|
|
1160
|
+
if mapping_info.get("category") == "vessels":
|
|
1161
|
+
if hasattr(component, "diameter"):
|
|
1162
|
+
diameter = component.diameter
|
|
1163
|
+
elif hasattr(component, "volume") and component.volume:
|
|
1164
|
+
# Approximate: assume L/D = 3 for horizontal, L/D = 4 for vertical
|
|
1165
|
+
V = component.volume
|
|
1166
|
+
diameter = (4 * V / (3 * np.pi)) ** (1 / 3) # Rough estimate
|
|
1167
|
+
|
|
1168
|
+
try:
|
|
1169
|
+
# Calculate bare module cost
|
|
1170
|
+
CBM = self.calculate_CBM(
|
|
1171
|
+
equipment_type=equipment_type,
|
|
1172
|
+
size=size,
|
|
1173
|
+
pressure=pressure,
|
|
1174
|
+
material=default_material,
|
|
1175
|
+
diameter=diameter,
|
|
1176
|
+
cepci_year=cepci_year,
|
|
1177
|
+
regional_factor=regional_factor,
|
|
1178
|
+
component_name=comp_name,
|
|
1179
|
+
component_type=comp_class,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
# Annualize and convert to hourly cost
|
|
1183
|
+
# Z = (CBM * CRF + OMC_first_year * CELF) / tau
|
|
1184
|
+
# where OMC_first_year = CBM * maintenance_factor
|
|
1185
|
+
OMC_first_year = CBM * maintenance_factor
|
|
1186
|
+
annual_cost = CBM * CRF + OMC_first_year * CELF
|
|
1187
|
+
hourly_cost = annual_cost / operating_hours
|
|
1188
|
+
|
|
1189
|
+
estimated_costs[f"{comp_name}_Z"] = hourly_cost
|
|
1190
|
+
|
|
1191
|
+
# Store details for reporting
|
|
1192
|
+
cost_details[comp_name] = {
|
|
1193
|
+
"equipment_type": equipment_type,
|
|
1194
|
+
"size": size,
|
|
1195
|
+
"size_unit": mapping_info.get("parameters", {}).get("size", {}).get("unit", ""),
|
|
1196
|
+
"pressure_barg": pressure,
|
|
1197
|
+
"material": default_material,
|
|
1198
|
+
"CBM_USD": CBM,
|
|
1199
|
+
"Z_hourly": hourly_cost,
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
except Exception as e:
|
|
1203
|
+
logging.warning(f"Failed to estimate cost for {comp_name}: {e}")
|
|
1204
|
+
continue
|
|
1205
|
+
|
|
1206
|
+
# Store cost details for later access
|
|
1207
|
+
self._estimated_cost_details = cost_details
|
|
1208
|
+
|
|
1209
|
+
# Warn about heat exchangers using default U values
|
|
1210
|
+
if hasattr(self, "_hx_using_default_U") and self._hx_using_default_U:
|
|
1211
|
+
logging.warning(
|
|
1212
|
+
"The following heat exchangers are using DEFAULT U values for area estimation. "
|
|
1213
|
+
"Provide custom_U_values parameter for accurate sizing:"
|
|
1214
|
+
)
|
|
1215
|
+
for comp_name, comp_class, default_U in self._hx_using_default_U:
|
|
1216
|
+
logging.warning(f" - {comp_name} ({comp_class}): U = {default_U} W/(m2-K)")
|
|
1217
|
+
|
|
1218
|
+
return estimated_costs
|
|
1219
|
+
|
|
1220
|
+
def print_estimated_costs(self):
|
|
1221
|
+
"""
|
|
1222
|
+
Print a summary table of estimated component costs.
|
|
1223
|
+
|
|
1224
|
+
Requires estimate_costs() to have been called first.
|
|
1225
|
+
"""
|
|
1226
|
+
if not hasattr(self, "_estimated_cost_details") or not self._estimated_cost_details:
|
|
1227
|
+
print("No estimated costs available. Run estimate_costs() first.")
|
|
1228
|
+
return
|
|
1229
|
+
|
|
1230
|
+
headers = [
|
|
1231
|
+
"Component",
|
|
1232
|
+
"Equipment Type",
|
|
1233
|
+
"Size",
|
|
1234
|
+
"Unit",
|
|
1235
|
+
"P [barg]",
|
|
1236
|
+
"Material",
|
|
1237
|
+
"CBM [USD]",
|
|
1238
|
+
f"Z [{self.currency}/h]",
|
|
1239
|
+
]
|
|
1240
|
+
rows = []
|
|
1241
|
+
|
|
1242
|
+
for comp_name, details in self._estimated_cost_details.items():
|
|
1243
|
+
rows.append(
|
|
1244
|
+
[
|
|
1245
|
+
comp_name,
|
|
1246
|
+
details["equipment_type"],
|
|
1247
|
+
f"{details['size']:.2f}",
|
|
1248
|
+
details["size_unit"],
|
|
1249
|
+
f"{details['pressure_barg']:.1f}",
|
|
1250
|
+
details["material"],
|
|
1251
|
+
f"{details['CBM_USD']:,.0f}",
|
|
1252
|
+
f"{details['Z_hourly']:.2f}",
|
|
1253
|
+
]
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
print("\n" + "=" * 100)
|
|
1257
|
+
print("ESTIMATED COMPONENT COSTS (Turton 2008 Correlations)")
|
|
1258
|
+
print("=" * 100)
|
|
1259
|
+
print(tabulate(rows, headers=headers, tablefmt="grid"))
|
|
1260
|
+
print()
|