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.
Files changed (48) hide show
  1. exerpy/__init__.py +2 -4
  2. exerpy/analyses.py +849 -304
  3. exerpy/components/__init__.py +3 -0
  4. exerpy/components/combustion/base.py +53 -35
  5. exerpy/components/component.py +8 -8
  6. exerpy/components/heat_exchanger/base.py +188 -121
  7. exerpy/components/heat_exchanger/condenser.py +98 -62
  8. exerpy/components/heat_exchanger/simple.py +237 -137
  9. exerpy/components/heat_exchanger/steam_generator.py +46 -41
  10. exerpy/components/helpers/cycle_closer.py +61 -34
  11. exerpy/components/helpers/power_bus.py +117 -0
  12. exerpy/components/nodes/deaerator.py +176 -58
  13. exerpy/components/nodes/drum.py +50 -39
  14. exerpy/components/nodes/flash_tank.py +218 -43
  15. exerpy/components/nodes/mixer.py +249 -69
  16. exerpy/components/nodes/splitter.py +173 -0
  17. exerpy/components/nodes/storage.py +130 -0
  18. exerpy/components/piping/valve.py +311 -115
  19. exerpy/components/power_machines/generator.py +105 -38
  20. exerpy/components/power_machines/motor.py +111 -39
  21. exerpy/components/turbomachinery/compressor.py +214 -68
  22. exerpy/components/turbomachinery/pump.py +215 -68
  23. exerpy/components/turbomachinery/turbine.py +182 -74
  24. exerpy/cost_estimation/__init__.py +65 -0
  25. exerpy/cost_estimation/turton.py +1260 -0
  26. exerpy/data/cost_correlations/cepci_index.json +135 -0
  27. exerpy/data/cost_correlations/component_mapping.json +450 -0
  28. exerpy/data/cost_correlations/material_factors.json +428 -0
  29. exerpy/data/cost_correlations/pressure_factors.json +206 -0
  30. exerpy/data/cost_correlations/turton2008.json +726 -0
  31. exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_tables.pdf +0 -0
  32. exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_theory.pdf +0 -0
  33. exerpy/functions.py +389 -264
  34. exerpy/parser/from_aspen/aspen_config.py +57 -48
  35. exerpy/parser/from_aspen/aspen_parser.py +373 -280
  36. exerpy/parser/from_ebsilon/__init__.py +2 -2
  37. exerpy/parser/from_ebsilon/check_ebs_path.py +15 -19
  38. exerpy/parser/from_ebsilon/ebsilon_config.py +328 -226
  39. exerpy/parser/from_ebsilon/ebsilon_functions.py +205 -38
  40. exerpy/parser/from_ebsilon/ebsilon_parser.py +392 -255
  41. exerpy/parser/from_ebsilon/utils.py +16 -11
  42. exerpy/parser/from_tespy/tespy_config.py +33 -1
  43. exerpy/parser/from_tespy/tespy_parser.py +151 -0
  44. {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/METADATA +43 -2
  45. exerpy-0.0.4.dist-info/RECORD +57 -0
  46. exerpy-0.0.2.dist-info/RECORD +0 -44
  47. {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/WHEEL +0 -0
  48. {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()