exerpy 0.0.1__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 (44) hide show
  1. exerpy/__init__.py +12 -0
  2. exerpy/analyses.py +1711 -0
  3. exerpy/components/__init__.py +16 -0
  4. exerpy/components/combustion/__init__.py +0 -0
  5. exerpy/components/combustion/base.py +248 -0
  6. exerpy/components/component.py +126 -0
  7. exerpy/components/heat_exchanger/__init__.py +0 -0
  8. exerpy/components/heat_exchanger/base.py +449 -0
  9. exerpy/components/heat_exchanger/condenser.py +323 -0
  10. exerpy/components/heat_exchanger/simple.py +358 -0
  11. exerpy/components/heat_exchanger/steam_generator.py +264 -0
  12. exerpy/components/helpers/__init__.py +0 -0
  13. exerpy/components/helpers/cycle_closer.py +104 -0
  14. exerpy/components/nodes/__init__.py +0 -0
  15. exerpy/components/nodes/deaerator.py +318 -0
  16. exerpy/components/nodes/drum.py +164 -0
  17. exerpy/components/nodes/flash_tank.py +89 -0
  18. exerpy/components/nodes/mixer.py +332 -0
  19. exerpy/components/piping/__init__.py +0 -0
  20. exerpy/components/piping/valve.py +394 -0
  21. exerpy/components/power_machines/__init__.py +0 -0
  22. exerpy/components/power_machines/generator.py +168 -0
  23. exerpy/components/power_machines/motor.py +173 -0
  24. exerpy/components/turbomachinery/__init__.py +0 -0
  25. exerpy/components/turbomachinery/compressor.py +318 -0
  26. exerpy/components/turbomachinery/pump.py +310 -0
  27. exerpy/components/turbomachinery/turbine.py +351 -0
  28. exerpy/data/Ahrendts.json +90 -0
  29. exerpy/functions.py +637 -0
  30. exerpy/parser/__init__.py +0 -0
  31. exerpy/parser/from_aspen/__init__.py +0 -0
  32. exerpy/parser/from_aspen/aspen_config.py +61 -0
  33. exerpy/parser/from_aspen/aspen_parser.py +721 -0
  34. exerpy/parser/from_ebsilon/__init__.py +38 -0
  35. exerpy/parser/from_ebsilon/check_ebs_path.py +74 -0
  36. exerpy/parser/from_ebsilon/ebsilon_config.py +1055 -0
  37. exerpy/parser/from_ebsilon/ebsilon_functions.py +181 -0
  38. exerpy/parser/from_ebsilon/ebsilon_parser.py +660 -0
  39. exerpy/parser/from_ebsilon/utils.py +79 -0
  40. exerpy/parser/from_tespy/tespy_config.py +23 -0
  41. exerpy-0.0.1.dist-info/METADATA +158 -0
  42. exerpy-0.0.1.dist-info/RECORD +44 -0
  43. exerpy-0.0.1.dist-info/WHEEL +4 -0
  44. exerpy-0.0.1.dist-info/licenses/LICENSE +21 -0
exerpy/analyses.py ADDED
@@ -0,0 +1,1711 @@
1
+ import json
2
+ import logging
3
+ import os
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from tabulate import tabulate
8
+
9
+ from .components.component import component_registry
10
+ from .components.helpers.cycle_closer import CycleCloser
11
+ from .functions import add_chemical_exergy
12
+ from .functions import add_total_exergy_flow
13
+
14
+
15
+ class ExergyAnalysis:
16
+ """
17
+ This class performs exergy analysis on energy system models from various simulation tools.
18
+ It parses input data, constructs component objects, calculates exergy flows,
19
+ and provides a comprehensive exergy balance for the overall system and individual components.
20
+ The class supports importing data from TESPy, Aspen, Ebsilon, or directly from JSON files.
21
+
22
+ Attributes
23
+ ----------
24
+ Tamb : float
25
+ Ambient temperature in K for reference environment.
26
+ pamb : float
27
+ Ambient pressure in Pa for reference environment.
28
+ _component_data : dict
29
+ Raw component data from the input model.
30
+ _connection_data : dict
31
+ Raw connection data from the input model.
32
+ chemExLib : object, optional
33
+ Chemical exergy library for chemical exergy calculations.
34
+ chemical_exergy_enabled : bool
35
+ Flag indicating if chemical exergy calculations are enabled.
36
+ split_physical_exergy : bool
37
+ Flag indicating if physical exergy is split into thermal and mechanical components.
38
+ components : dict
39
+ Dictionary of component objects constructed from input data.
40
+ connections : dict
41
+ Dictionary of connection data with exergy values.
42
+ E_F : float
43
+ Total fuel exergy for the overall system in W.
44
+ E_P : float
45
+ Total product exergy for the overall system in W.
46
+ E_L : float
47
+ Total loss exergy for the overall system in W.
48
+ E_D : float
49
+ Total exergy destruction for the overall system in W.
50
+ E_F_dict : dict
51
+ Dictionary specifying fuel connections.
52
+ E_P_dict : dict
53
+ Dictionary specifying product connections.
54
+ E_L_dict : dict
55
+ Dictionary specifying loss connections.
56
+ epsilon : float
57
+ Overall exergy efficiency of the system.
58
+
59
+ Methods
60
+ -------
61
+ analyse(E_F, E_P, E_L={})
62
+ Performs exergy analysis based on specified fuel, product, and loss definitions.
63
+ from_tespy(model, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True)
64
+ Creates an instance from a TESPy network model.
65
+ from_aspen(path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True)
66
+ Creates an instance from an Aspen model file.
67
+ from_ebsilon(path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True)
68
+ Creates an instance from an Ebsilon model file.
69
+ from_json(json_path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True)
70
+ Creates an instance from a JSON file containing system data.
71
+ exergy_results(print_results=True)
72
+ Displays and returns tables of exergy analysis results.
73
+ export_to_json(output_path)
74
+ Exports the model and analysis results to a JSON file.
75
+ _serialize()
76
+ """
77
+
78
+ def __init__(self, component_data, connection_data, Tamb, pamb, chemExLib=None, split_physical_exergy=True) -> None:
79
+ """
80
+ Constructor for ExergyAnalysis. It parses the provided simulation file and prepares it for exergy analysis.
81
+
82
+ Parameters
83
+ ----------
84
+ component_data : dict
85
+ Data of the components.
86
+ connection_data : dict
87
+ Data of the connections.
88
+ Tamb : float
89
+ Ambient temperature (K).
90
+ pamb : float
91
+ Ambient pressure (Pa).
92
+ chemical_exergy_enabled : bool, optional
93
+ Flag to enable chemical exergy calculations (default is False).
94
+ split_physical_exergy : bool, optional
95
+ Flag to determine if physical exergy should be split into thermal and mechanical exergy (default is False).
96
+ """
97
+ self.Tamb = Tamb
98
+ self.pamb = pamb
99
+ self._component_data = component_data
100
+ self._connection_data = connection_data
101
+ self.chemExLib = chemExLib
102
+ self.chemical_exergy_enabled = self.chemExLib is not None
103
+ self.split_physical_exergy = split_physical_exergy
104
+
105
+ # Convert the parsed data into components
106
+ self.components = _construct_components(component_data, connection_data, Tamb)
107
+ self.connections = connection_data
108
+
109
+ def analyse(self, E_F, E_P, E_L={}) -> None:
110
+ """
111
+ Run the exergy analysis for the entire system and calculate overall exergy efficiency.
112
+
113
+ Parameters
114
+ ----------
115
+ E_F : dict
116
+ Dictionary containing input connections for fuel exergy (e.g., {"inputs": ["1", "2"]}).
117
+ E_P : dict
118
+ Dictionary containing input and output connections for product exergy (e.g., {"inputs": ["E1"], "outputs": ["T1", "T2"]}).
119
+ E_L : dict, optional
120
+ Dictionary containing input and output connections for loss exergy (default is {}).
121
+ """
122
+ # Initialize class attributes for the exergy value of the total system
123
+ self.E_F = 0.0
124
+ self.E_P = 0.0
125
+ self.E_L = 0.0
126
+ self.E_F_dict = E_F
127
+ self.E_P_dict = E_P
128
+ self.E_L_dict = E_L
129
+
130
+ for ex_flow in [E_F, E_P, E_L]:
131
+ for connections in ex_flow.values():
132
+ for connection in connections:
133
+ if connection not in self.connections:
134
+ msg = (
135
+ f"The connection {connection} is not part of the "
136
+ "plant's connections."
137
+ )
138
+ raise ValueError(msg)
139
+
140
+ # Calculate total fuel exergy (E_F) by summing up all specified input connections
141
+ if "inputs" in E_F:
142
+ self.E_F += sum(
143
+ self.connections[conn]['E']
144
+ for conn in E_F["inputs"]
145
+ if self.connections[conn]['E'] is not None
146
+ )
147
+
148
+ if "outputs" in E_F:
149
+ self.E_F -= sum(
150
+ self.connections[conn]['E']
151
+ for conn in E_F["outputs"]
152
+ if self.connections[conn]['E'] is not None
153
+ )
154
+
155
+ # Calculate total product exergy (E_P) by summing up all specified input and output connections
156
+ if "inputs" in E_P:
157
+ self.E_P += sum(
158
+ self.connections[conn]['E']
159
+ for conn in E_P["inputs"]
160
+ if self.connections[conn]['E'] is not None
161
+ )
162
+ if "outputs" in E_P:
163
+ self.E_P -= sum(
164
+ self.connections[conn]['E']
165
+ for conn in E_P["outputs"]
166
+ if self.connections[conn]['E'] is not None
167
+ )
168
+
169
+ # Calculate total loss exergy (E_L) by summing up all specified input and output connections
170
+ if "inputs" in E_L:
171
+ self.E_L += sum(
172
+ self.connections[conn]['E']
173
+ for conn in E_L["inputs"]
174
+ if self.connections[conn]['E'] is not None
175
+ )
176
+ if "outputs" in E_L:
177
+ self.E_L -= sum(
178
+ self.connections[conn]['E']
179
+ for conn in E_L["outputs"]
180
+ if self.connections[conn]['E'] is not None
181
+ )
182
+
183
+ # Calculate overall exergy efficiency epsilon = E_P / E_F
184
+ # E_F == 0 should throw an error because it does not make sense
185
+ self.epsilon = self.E_P / self.E_F if self.E_F != 0 else None
186
+
187
+ # The rest is counted as total exergy destruction with all components of the system
188
+ self.E_D = self.E_F - self.E_P - self.E_L
189
+
190
+ if self.epsilon is not None:
191
+ eff_str = f"{self.epsilon:.2%}"
192
+ else:
193
+ eff_str = "N/A"
194
+ logging.info(
195
+ f"Overall exergy analysis completed: E_F = {self.E_F:.2f} kW, "
196
+ f"E_P = {self.E_P:.2f} kW, E_L = {self.E_L:.2f} kW, "
197
+ f"Efficiency = {eff_str}"
198
+ )
199
+
200
+ # Perform exergy balance for each individual component in the system
201
+ total_component_E_D = 0.0
202
+ for component_name, component in self.components.items():
203
+ if component.__class__.__name__ == "CycleCloser":
204
+ continue
205
+ else:
206
+ # Calculate E_F, E_D, E_P
207
+ component.calc_exergy_balance(self.Tamb, self.pamb, self.split_physical_exergy)
208
+ # Safely calculate y and y* avoiding division by zero
209
+ if self.E_F != 0:
210
+ component.y = component.E_D / self.E_F
211
+ component.y_star = component.E_D / self.E_D if component.E_D is not None else None
212
+ else:
213
+ component.y = None
214
+ component.y_star = None
215
+ # Sum component destruction if available
216
+ if component.E_D is not None:
217
+ total_component_E_D += component.E_D
218
+
219
+ # Check if the sum of all component exergy destructions matches the overall system exergy destruction
220
+ if not np.isclose(total_component_E_D, self.E_D, rtol=1e-5):
221
+ logging.warning(f"Sum of component exergy destructions ({total_component_E_D:.2f} W) "
222
+ f"does not match overall system exergy destruction ({self.E_D:.2f} W).")
223
+ else:
224
+ logging.info(f"Exergy destruction check passed: Sum of component E_D matches overall E_D.")
225
+
226
+ @classmethod
227
+ def from_tespy(cls, model: str, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
228
+ """
229
+ Create an instance of the ExergyAnalysis class from a tespy network or
230
+ a tespy network export structure.
231
+
232
+ Parameters
233
+ ----------
234
+ model : str | tespy.networks.network.Network
235
+ Path to the tespy Network export or the actual Network instance.
236
+ Tamb : float, optional
237
+ Ambient temperature for analysis, default is None.
238
+ pamb : float, optional
239
+ Ambient pressure for analysis, default is None.
240
+ chemExLib : str, optional
241
+ Name of the library for chemical exergy tables.
242
+
243
+ Returns
244
+ -------
245
+ ExergyAnalysis
246
+ Instance of the ExergyAnalysis class.
247
+ """
248
+ from tespy.networks import Network
249
+
250
+ from .parser.from_tespy.tespy_config import EXERPY_TESPY_MAPPINGS
251
+
252
+ if isinstance(model, str):
253
+ model = Network.from_json(model)
254
+ elif isinstance(model, Network):
255
+ pass
256
+ else:
257
+ msg = (
258
+ "Model parameter must be a path to a valid tespy network "
259
+ "export or a tespy network"
260
+ )
261
+ raise TypeError(msg)
262
+
263
+ data = model.to_exerpy(Tamb, pamb, EXERPY_TESPY_MAPPINGS)
264
+ data, Tamb, pamb = _process_json(data, Tamb, pamb, chemExLib, split_physical_exergy)
265
+ return cls(data['components'], data['connections'], Tamb, pamb, chemExLib, split_physical_exergy)
266
+
267
+ @classmethod
268
+ def from_aspen(cls, path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
269
+ """
270
+ Create an instance of the ExergyAnalysis class from an Aspen model file.
271
+
272
+ Parameters
273
+ ----------
274
+ path : str
275
+ Path to the Ebsilon file (.bkp format).
276
+ Tamb : float, optional
277
+ Ambient temperature for analysis, default is None.
278
+ pamb : float, optional
279
+ Ambient pressure for analysis, default is None.
280
+ chemExLib : str, optional
281
+ Name of the chemical exergy library (if any).
282
+ split_physical_exergy : bool, optional
283
+ If True, separates physical exergy into thermal and mechanical components.
284
+
285
+ Returns
286
+ -------
287
+ ExergyAnalysis
288
+ An instance of the ExergyAnalysis class with parsed Ebsilon data.
289
+ """
290
+
291
+ from .parser.from_aspen import aspen_parser as aspen_parser
292
+
293
+ # Check if the file is an Aspen file
294
+ _, file_extension = os.path.splitext(path)
295
+
296
+ if file_extension == '.bkp':
297
+ logging.info("Running Ebsilon simulation and generating JSON data.")
298
+ data = aspen_parser.run_aspen(path, split_physical_exergy=split_physical_exergy)
299
+ logging.info("Simulation completed successfully.")
300
+
301
+ else:
302
+ # If the file format is not supported
303
+ raise ValueError(
304
+ f"Unsupported file format: {file_extension}. Please provide "
305
+ "an Ebsilon (.bkp) file."
306
+ )
307
+
308
+ data, Tamb, pamb = _process_json(
309
+ data, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy,
310
+ required_component_fields=["name", "type"]
311
+ )
312
+ return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
313
+
314
+ @classmethod
315
+ def from_ebsilon(cls, path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
316
+ """
317
+ Create an instance of the ExergyAnalysis class from an Ebsilon model file.
318
+
319
+ Parameters
320
+ ----------
321
+ path : str
322
+ Path to the Ebsilon file (.ebs format).
323
+ Tamb : float, optional
324
+ Ambient temperature for analysis, default is None.
325
+ pamb : float, optional
326
+ Ambient pressure for analysis, default is None.
327
+ chemExLib : str, optional
328
+ Name of the chemical exergy library (if any).
329
+ split_physical_exergy : bool, optional
330
+ If True, separates physical exergy into thermal and mechanical components.
331
+
332
+ Returns
333
+ -------
334
+ ExergyAnalysis
335
+ An instance of the ExergyAnalysis class with parsed Ebsilon data.
336
+ """
337
+
338
+ from .parser.from_ebsilon import ebsilon_parser as ebs_parser
339
+
340
+ # Check if the file is an Ebsilon file
341
+ _, file_extension = os.path.splitext(path)
342
+
343
+ if file_extension == '.ebs':
344
+ logging.info("Running Ebsilon simulation and generating JSON data.")
345
+ data = ebs_parser.run_ebsilon(path, split_physical_exergy=split_physical_exergy)
346
+ logging.info("Simulation completed successfully.")
347
+
348
+ else:
349
+ # If the file format is not supported
350
+ raise ValueError(
351
+ f"Unsupported file format: {file_extension}. Please provide "
352
+ "an Ebsilon (.ebs) file."
353
+ )
354
+
355
+ data, Tamb, pamb = _process_json(
356
+ data, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy,
357
+ required_component_fields=["name", "type", "type_index"]
358
+ )
359
+ return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
360
+
361
+ @classmethod
362
+ def from_json(cls, json_path: str, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
363
+ """
364
+ Create an ExergyAnalysis instance from a JSON file.
365
+
366
+ Parameters
367
+ ----------
368
+ json_path : str
369
+ Path to JSON file containing component and connection data.
370
+ Tamb : float, optional
371
+ Ambient temperature in K. If None, extracted from JSON.
372
+ pamb : float, optional
373
+ Ambient pressure in Pa. If None, extracted from JSON.
374
+ chemExLib : str, optional
375
+ Name of chemical exergy library to use. Default is None.
376
+
377
+ Returns
378
+ -------
379
+ ExergyAnalysis
380
+ Configured instance with data from JSON file.
381
+
382
+ Raises
383
+ ------
384
+ FileNotFoundError
385
+ If JSON file does not exist.
386
+ ValueError
387
+ If JSON structure is invalid or missing required data.
388
+ JSONDecodeError
389
+ If JSON file is malformed.
390
+ """
391
+ data = _load_json(json_path)
392
+ data, Tamb, pamb = _process_json(
393
+ data, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy
394
+ )
395
+ return cls(data['components'], data['connections'], Tamb, pamb, chemExLib, split_physical_exergy)
396
+
397
+ def exergy_results(self, print_results=True):
398
+ """
399
+ Displays a table of exergy analysis results with columns for E_F, E_P, E_D, and epsilon for each component,
400
+ and additional information for material and non-material connections.
401
+
402
+ CycleCloser components are excluded from the component results.
403
+
404
+ Parameters
405
+ ----------
406
+ print_results : bool, optional
407
+ If True, prints the results as tables in the console (default is True).
408
+
409
+ Returns
410
+ -------
411
+ tuple of pandas.DataFrame
412
+ (df_component_results, df_material_connection_results, df_non_material_connection_results)
413
+ with the exergy analysis results.
414
+ """
415
+ # Define the lambda function for safe multiplication
416
+ convert = lambda x, factor: x * factor if x is not None else None
417
+
418
+ # COMPONENTS
419
+ component_results = {
420
+ "Component": [],
421
+ "E_F [kW]": [],
422
+ "E_P [kW]": [],
423
+ "E_D [kW]": [],
424
+ "E_L [kW]": [],
425
+ "ε [%]": [],
426
+ "y [%]": [],
427
+ "y* [%]": []
428
+ }
429
+
430
+ # Populate the dictionary with exergy analysis data from each component,
431
+ # excluding CycleCloser components.
432
+ for component_name, component in self.components.items():
433
+ # Exclude components whose class name is "CycleCloser"
434
+ if component.__class__.__name__ == "CycleCloser":
435
+ continue
436
+
437
+ component_results["Component"].append(component_name)
438
+ # Convert E_F, E_P, E_D, E_L from W to kW and epsilon to percentage using the lambda
439
+ E_F_kW = convert(component.E_F, 1e-3)
440
+ E_P_kW = convert(component.E_P, 1e-3)
441
+ E_D_kW = convert(component.E_D, 1e-3)
442
+ E_L_kW = convert(getattr(component, 'E_L', None), 1e-3) if getattr(component, 'E_L', None) is not None else 0
443
+ epsilon_percent = convert(component.epsilon, 1e2)
444
+
445
+ component_results["E_F [kW]"].append(E_F_kW)
446
+ component_results["E_P [kW]"].append(E_P_kW)
447
+ component_results["E_D [kW]"].append(E_D_kW + E_L_kW)
448
+ component_results["E_L [kW]"].append(0)
449
+ component_results["ε [%]"].append(epsilon_percent)
450
+ component_results["y [%]"].append(convert(component.y, 1e2))
451
+ component_results["y* [%]"].append(convert(component.y_star, 1e2))
452
+
453
+ # Convert the component dictionary into a pandas DataFrame
454
+ df_component_results = pd.DataFrame(component_results)
455
+
456
+ # Sort the DataFrame by the "Component" column
457
+ df_component_results = df_component_results.sort_values(by="Component")
458
+
459
+ # Add the overall results to the components as dummy component "TOT"
460
+ df_component_results.loc["TOT", "E_F [kW]"] = convert(self.E_F, 1e-3)
461
+ df_component_results.loc["TOT", "Component"] = 'TOT'
462
+ df_component_results.loc["TOT", "E_L [kW]"] = convert(self.E_L, 1e-3)
463
+ df_component_results.loc["TOT", "E_P [kW]"] = convert(self.E_P, 1e-3)
464
+ df_component_results.loc["TOT", "E_D [kW]"] = convert(self.E_D, 1e-3)
465
+ df_component_results.loc["TOT", "ε [%]"] = convert(self.epsilon, 1e2)
466
+ # Calculate the total y [%] and y* [%] as the sum of the values for all components
467
+ df_component_results.loc["TOT", "y [%]"] = df_component_results["y [%]"].sum()
468
+ df_component_results.loc["TOT", "y* [%]"] = df_component_results["y* [%]"].sum()
469
+
470
+ # MATERIAL CONNECTIONS
471
+ material_connection_results = {
472
+ "Connection": [],
473
+ "m [kg/s]": [],
474
+ "T [°C]": [],
475
+ "p [bar]": [],
476
+ "h [kJ/kg]": [],
477
+ "s [J/kgK]": [],
478
+ "E [kW]": [],
479
+ "e^PH [kJ/kg]": [],
480
+ "e^T [kJ/kg]": [],
481
+ "e^M [kJ/kg]": [],
482
+ "e^CH [kJ/kg]": []
483
+ }
484
+
485
+ # NON-MATERIAL CONNECTIONS
486
+ non_material_connection_results = {
487
+ "Connection": [],
488
+ "Kind": [],
489
+ "Energy Flow [kW]": [],
490
+ "Exergy Flow [kW]": []
491
+ }
492
+
493
+ # Populate the dictionaries with exergy analysis data for each connection
494
+ for conn_name, conn_data in self.connections.items():
495
+ # Separate material and non-material connections based on fluid type
496
+ kind = conn_data.get("kind", None)
497
+
498
+ # Check if the connection is a non-material energy flow type
499
+ if kind in {'power', 'heat'}:
500
+ # Non-material connections: only record energy flow, converted to kW using lambda
501
+ non_material_connection_results["Connection"].append(conn_name)
502
+ non_material_connection_results["Kind"].append(kind)
503
+ non_material_connection_results["Energy Flow [kW]"].append(convert(conn_data.get("energy_flow"), 1e-3))
504
+ non_material_connection_results["Exergy Flow [kW]"].append(convert(conn_data.get("E"), 1e-3))
505
+ elif kind == 'material':
506
+ # Material connections: record full data with conversions using lambda
507
+ material_connection_results["Connection"].append(conn_name)
508
+ material_connection_results["m [kg/s]"].append(conn_data.get('m', None))
509
+ material_connection_results["T [°C]"].append(conn_data.get('T') - 273.15) # Convert to °C
510
+ material_connection_results["p [bar]"].append(convert(conn_data.get('p'), 1e-5)) # Convert Pa to bar
511
+ material_connection_results["h [kJ/kg]"].append(convert(conn_data.get('h'), 1e-3)) # Convert to kJ/kg
512
+ material_connection_results["s [J/kgK]"].append(conn_data.get('s', None))
513
+ material_connection_results["e^PH [kJ/kg]"].append(convert(conn_data.get('e_PH'), 1e-3)) # Convert to kJ/kg
514
+ material_connection_results["e^T [kJ/kg]"].append(convert(conn_data.get('e_T'), 1e-3)) # Convert to kJ/kg
515
+ material_connection_results["e^M [kJ/kg]"].append(convert(conn_data.get('e_M'), 1e-3)) # Convert to kJ/kg
516
+ material_connection_results["e^CH [kJ/kg]"].append(convert(conn_data.get('e_CH'), 1e-3)) # Convert to kJ/kg
517
+ material_connection_results["E [kW]"].append(convert(conn_data.get("E"), 1e-3)) # Convert to kW
518
+
519
+ # Convert the material and non-material connection dictionaries into DataFrames
520
+ df_material_connection_results = pd.DataFrame(material_connection_results)
521
+ df_non_material_connection_results = pd.DataFrame(non_material_connection_results)
522
+
523
+ # Sort the DataFrames by the "Connection" column
524
+ df_material_connection_results = df_material_connection_results.sort_values(by="Connection")
525
+ df_non_material_connection_results = df_non_material_connection_results.sort_values(by="Connection")
526
+
527
+ if print_results:
528
+ # Print the material connection results DataFrame in the console in a table format
529
+ print("\nMaterial Connection Exergy Analysis Results:")
530
+ print(tabulate(df_material_connection_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
531
+
532
+ # Print the non-material connection results DataFrame in the console in a table format
533
+ print("\nNon-Material Connection Exergy Analysis Results:")
534
+ print(tabulate(df_non_material_connection_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
535
+
536
+ # Print the component results DataFrame in the console in a table format
537
+ print("\nComponent Exergy Analysis Results:")
538
+ print(tabulate(df_component_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
539
+
540
+ return df_component_results, df_material_connection_results, df_non_material_connection_results
541
+
542
+ def export_to_json(self, output_path):
543
+ """
544
+ Export the model to a JSON file.
545
+
546
+ Parameters
547
+ ----------
548
+ output_path : str
549
+ Path where the JSON file will be saved.
550
+
551
+ Returns
552
+ -------
553
+ None
554
+ The model is saved to the specified path.
555
+
556
+ Notes
557
+ -----
558
+ This method serializes the model using the internal _serialize method
559
+ and writes the resulting data to a JSON file with indentation.
560
+ """
561
+ data = self._serialize()
562
+ with open(output_path, 'w') as json_file:
563
+ json.dump(data, json_file, indent=4)
564
+ logging.info(f"Model exported to JSON file: {output_path}.")
565
+
566
+ def _serialize(self):
567
+ """
568
+ Serializes the analysis data into a dictionary for export.
569
+ Returns
570
+ -------
571
+ export : dict
572
+ Dictionary containing serialized data with the following structure:
573
+ - components: Component data
574
+ - connections: Connection data
575
+ - ambient_conditions: Ambient temperature and pressure with units
576
+ - settings: Analysis settings including exergy splitting mode and chemical exergy library
577
+ """
578
+ export = {}
579
+ export["components"] = self._component_data
580
+ export["connections"] = self._connection_data
581
+ export["ambient_conditions"] = {
582
+ "Tamb": self.Tamb,
583
+ "Tamb_unit": "K",
584
+ "pamb": self.pamb,
585
+ "pamb_unit": "Pa"
586
+ }
587
+ export["settings"] = {
588
+ "split_physical_exergy": self.split_physical_exergy,
589
+ "chemExLib": self.chemExLib
590
+ }
591
+
592
+ return export
593
+
594
+
595
+ def _construct_components(component_data, connection_data, Tamb):
596
+ """
597
+ Constructs component instances from component and connection data.
598
+ Parameters
599
+ ----------
600
+ component_data : dict
601
+ Dictionary containing component data organized by type.
602
+ Format: {component_type: {component_name: {parameters}}}
603
+ connection_data : dict
604
+ Dictionary containing connection information between components.
605
+ Each connection contains source and target component information.
606
+ Tamb : float
607
+ Ambient temperature, used for determining if a valve is dissipative.
608
+ Returns
609
+ -------
610
+ dict
611
+ Dictionary of instantiated components, with component names as keys.
612
+ Notes
613
+ -----
614
+ Skips components of type 'Splitter'. For valves, automatically determines if they
615
+ are dissipative by comparing inlet and outlet temperatures to ambient temperature.
616
+ """
617
+ components = {} # Initialize a dictionary to store created components
618
+
619
+ # Loop over component types (e.g., 'Combustion Chamber', 'Compressor')
620
+ for component_type, component_instances in component_data.items():
621
+ for component_name, component_information in component_instances.items():
622
+ # Skip components of type 'Splitter'
623
+ if component_type == "Splitter" or component_information.get('type') == "Splitter":
624
+ logging.info(f"Skipping 'Splitter' component during the exergy analysis: {component_name}")
625
+ continue # Skip this component
626
+
627
+ # Fetch the corresponding class from the registry using the component type
628
+ component_class = component_registry.items.get(component_type)
629
+
630
+ if component_class is None:
631
+ logging.warning(f"Component type '{component_type}' is not registered.")
632
+ continue
633
+
634
+ # Instantiate the component with its attributes
635
+ component = component_class(**component_information)
636
+
637
+ # Initialize empty dictionaries for inlets and outlets
638
+ component.inl = {}
639
+ component.outl = {}
640
+
641
+ # Assign streams to the components based on connection data
642
+ for conn_id, conn_info in connection_data.items():
643
+ # Assign inlet streams
644
+ if conn_info['target_component'] == component_name:
645
+ target_connector_idx = conn_info['target_connector'] # Use 0-based indexing
646
+ component.inl[target_connector_idx] = conn_info # Assign inlet stream
647
+
648
+ # Assign outlet streams
649
+ if conn_info['source_component'] == component_name:
650
+ source_connector_idx = conn_info['source_connector'] # Use 0-based indexing
651
+ component.outl[source_connector_idx] = conn_info # Assign outlet stream
652
+
653
+ # --- NEW: Automatically mark Valve components as dissipative ---
654
+ # Here we assume that if a Valve's first inlet and first outlet have temperatures (key "T")
655
+ # above the ambient temperature (Tamb), it is dissipative.
656
+ if component_type == "Valve":
657
+ try:
658
+ # Grab the temperature from the first inlet and outlet
659
+ T_in = list(component.inl.values())[0].get("T", None)
660
+ T_out = list(component.outl.values())[0].get("T", None)
661
+ if T_in is not None and T_out is not None and T_in > Tamb and T_out > Tamb:
662
+ component.is_dissipative = True
663
+ else:
664
+ component.is_dissipative = False
665
+ except Exception as e:
666
+ logging.warning(f"Could not evaluate dissipativity for Valve '{component_name}': {e}")
667
+ component.is_dissipative = False
668
+ else:
669
+ component.is_dissipative = False
670
+
671
+ # Store the component in the dictionary
672
+ components[component_name] = component
673
+
674
+ return components # Return the dictionary of created components
675
+
676
+
677
+ def _load_json(json_path):
678
+ """
679
+ Load and validate a JSON file.
680
+ Parameters
681
+ ----------
682
+ json_path : str
683
+ Path to the JSON file to load.
684
+ Returns
685
+ -------
686
+ dict
687
+ The loaded JSON content.
688
+ Raises
689
+ ------
690
+ FileNotFoundError
691
+ If the specified file does not exist.
692
+ ValueError
693
+ If the file does not have a .json extension.
694
+ json.JSONDecodeError
695
+ If the file content is not valid JSON.
696
+ """
697
+ # Check file existence and extension
698
+ if not os.path.exists(json_path):
699
+ raise FileNotFoundError(f"File not found: {json_path}")
700
+
701
+ if not json_path.endswith('.json'):
702
+ raise ValueError("File must have .json extension")
703
+
704
+ # Load and validate JSON
705
+ with open(json_path, 'r') as file:
706
+ return json.load(file)
707
+
708
+
709
+ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True, required_component_fields=['name']):
710
+ """Process JSON data to prepare it for exergy analysis.
711
+ This function validates the data structure, ensures all required fields are present,
712
+ and enriches the data with chemical exergy and total exergy flow calculations.
713
+ Parameters
714
+ ----------
715
+ data : dict
716
+ Dictionary containing system data with components, connections and ambient conditions
717
+ Tamb : float, optional
718
+ Ambient temperature in K, overrides the value in data if provided
719
+ pamb : float, optional
720
+ Ambient pressure in Pa, overrides the value in data if provided
721
+ chemExLib : dict, optional
722
+ Chemical exergy library for reference values
723
+ split_physical_exergy : bool, default=True
724
+ Whether to split physical exergy into thermal and mechanical parts
725
+ required_component_fields : list, default=['name']
726
+ List of fields that must be present in each component
727
+ Returns
728
+ -------
729
+ tuple
730
+ (processed_data, ambient_temperature, ambient_pressure)
731
+ Raises
732
+ ------
733
+ ValueError
734
+ If required sections or fields are missing, or if data structure is invalid
735
+ """
736
+ # Validate required sections
737
+ required_sections = ['components', 'connections', 'ambient_conditions']
738
+ missing_sections = [s for s in required_sections if s not in data]
739
+ if missing_sections:
740
+ raise ValueError(f"Missing required sections: {missing_sections}")
741
+
742
+ # Check for mass_composition in material streams if chemical exergy is requested
743
+ if chemExLib:
744
+ for conn_name, conn_data in data['connections'].items():
745
+ if conn_data.get('kind') == 'material' and 'mass_composition' not in conn_data:
746
+ raise ValueError(f"Material stream '{conn_name}' missing mass_composition")
747
+
748
+ # Extract or use provided ambient conditions
749
+ Tamb = Tamb or data['ambient_conditions'].get('Tamb')
750
+ pamb = pamb or data['ambient_conditions'].get('pamb')
751
+
752
+ if Tamb is None or pamb is None:
753
+ raise ValueError("Ambient conditions (Tamb, pamb) must be provided either in JSON or as parameters")
754
+
755
+ # Validate component data structure
756
+ if not isinstance(data['components'], dict):
757
+ raise ValueError("Components section must be a dictionary")
758
+
759
+ for comp_type, components in data['components'].items():
760
+ if not isinstance(components, dict):
761
+ raise ValueError(f"Component type '{comp_type}' must contain dictionary of components")
762
+
763
+ for comp_name, comp_data in components.items():
764
+ missing_fields = [f for f in required_component_fields if f not in comp_data]
765
+ if missing_fields:
766
+ raise ValueError(f"Component '{comp_name}' missing required fields: {missing_fields}")
767
+
768
+ # Validate connection data structure
769
+ for conn_name, conn_data in data['connections'].items():
770
+ required_conn_fields = ['kind', 'source_component', 'target_component']
771
+ missing_fields = [f for f in required_conn_fields if f not in conn_data]
772
+ if missing_fields:
773
+ raise ValueError(f"Connection '{conn_name}' missing required fields: {missing_fields}")
774
+
775
+ # Add chemical exergy if library provided
776
+ if chemExLib:
777
+ data = add_chemical_exergy(data, Tamb, pamb, chemExLib)
778
+ logging.info("Added chemical exergy values")
779
+ else:
780
+ logging.warning("You haven't provided a chemical exergy library. Chemical exergy values will not be added.")
781
+
782
+ # Calculate total exergy flows
783
+ data = add_total_exergy_flow(data, split_physical_exergy)
784
+ logging.info("Added total exergy flows")
785
+
786
+ return data, Tamb, pamb
787
+
788
+
789
+ class ExergoeconomicAnalysis:
790
+ """"
791
+ This class performs exergoeconomic analysis on a previously completed exergy analysis.
792
+ It takes the results from an ExergyAnalysis instance and builds upon them
793
+ to conduct a complete exergoeconomic analysis. It constructs and solves a system
794
+ of linear equations to determine the costs (both total and specific) associated
795
+ with each exergy stream in the system, and calculates various exergoeconomic indicators
796
+ for each component.
797
+
798
+ Attributes
799
+ ----------
800
+ exergy_analysis : ExergyAnalysis
801
+ The exergy analysis instance used as the basis for calculations.
802
+ connections : dict
803
+ Dictionary of all energy/material connections in the system.
804
+ components : dict
805
+ Dictionary of all components in the system.
806
+ chemical_exergy_enabled : bool
807
+ Flag indicating if chemical exergy is considered in calculations.
808
+ E_F_dict : dict
809
+ Dictionary mapping fuel streams to components.
810
+ E_P_dict : dict
811
+ Dictionary mapping product streams to components.
812
+ E_L_dict : dict
813
+ Dictionary mapping loss streams to components.
814
+ num_variables : int
815
+ Number of cost variables in the exergoeconomic equations.
816
+ variables : dict
817
+ Dictionary mapping variable indices to variable names.
818
+ equations : dict
819
+ Dictionary mapping equation indices to equation types.
820
+ currency : str
821
+ Currency symbol used in cost reporting.
822
+ system_costs : dict
823
+ Dictionary of system-level costs after analysis.
824
+
825
+ Methods
826
+ -------
827
+ initialize_cost_variables()
828
+ Defines and indexes all cost variables in the system.
829
+ assign_user_costs(Exe_Eco_Costs)
830
+ Assigns user-defined costs to components and input streams.
831
+ construct_matrix(Tamb)
832
+ Constructs the linear equation system for exergoeconomic analysis.
833
+ solve_exergoeconomic_analysis(Tamb)
834
+ Solves the cost equations and assigns results to connections and components.
835
+ run(Exe_Eco_Costs, Tamb)
836
+ Executes the complete exergoeconomic analysis workflow.
837
+ exergoeconomic_results(print_results=True)
838
+ Displays and returns tables of exergoeconomic analysis results.
839
+ """
840
+
841
+ def __init__(self, exergy_analysis_instance, currency="EUR"):
842
+ """
843
+ Initialize an economic analysis for an exergy analysis.
844
+
845
+ Parameters
846
+ ----------
847
+ exergy_analysis_instance : ExergyAnalysis
848
+ Instance of ExergyAnalysis that has already performed exergy calculations.
849
+ currency : str, optional
850
+ Currency symbol for cost calculations, by default "EUR".
851
+
852
+ Notes
853
+ -----
854
+ This class inherits all exergy analysis results from the provided instance
855
+ and prepares data structures for economic equations and cost variables.
856
+ """
857
+ self.exergy_analysis = exergy_analysis_instance
858
+ self.connections = exergy_analysis_instance.connections
859
+ self.components = exergy_analysis_instance.components
860
+ self.chemical_exergy_enabled = exergy_analysis_instance.chemical_exergy_enabled
861
+ self.E_F_dict = exergy_analysis_instance.E_F_dict
862
+ self.E_P_dict = exergy_analysis_instance.E_P_dict
863
+ self.E_L_dict = exergy_analysis_instance.E_L_dict
864
+ self.num_variables = 0 # Track number of equations (or cost variables) for the matrix
865
+ self.variables = {} # New dictionary to map variable indices to names
866
+ self.equations = {} # New dictionary to map equation indices to kind of equation
867
+ self.currency = currency # EUR is default currency for cost calculations
868
+
869
+ def initialize_cost_variables(self):
870
+ """
871
+ Initialize cost variables for the exergoeconomic analysis.
872
+
873
+ This method assigns unique indices to each cost variable in the matrix system
874
+ and populates a dictionary mapping these indices to variable names.
875
+
876
+ For material streams, separate indices are assigned for thermal, mechanical,
877
+ and chemical exergy components when chemical exergy is enabled (otherwise only
878
+ thermal and mechanical). For non-material streams (heat, power), a single index
879
+ is assigned for the total exergy cost.
880
+
881
+ Notes
882
+ -----
883
+ The assigned indices are used for constructing the cost balance equations
884
+ in the matrix system that will be solved to find all cost variables.
885
+ """
886
+ col_number = 0
887
+ valid_components = {comp.name for comp in self.components.values()}
888
+
889
+ # Process each connection (stream) which is part of the system (has a valid source or target)
890
+ for name, conn in self.connections.items():
891
+ conn["name"] = name # Add the connection name to the dictionary
892
+ is_part_of_the_system = (conn.get("source_component") in valid_components) or \
893
+ (conn.get("target_component") in valid_components)
894
+ if not is_part_of_the_system:
895
+ continue
896
+ else:
897
+ kind = conn.get("kind", "material")
898
+ # For material streams, assign indices based on the flag.
899
+ if kind == "material":
900
+ if self.exergy_analysis.chemical_exergy_enabled:
901
+ conn["CostVar_index"] = {
902
+ "T": col_number,
903
+ "M": col_number + 1,
904
+ "CH": col_number + 2
905
+ }
906
+ self.variables[str(col_number)] = f"C_{name}_T"
907
+ self.variables[str(col_number + 1)] = f"C_{name}_M"
908
+ self.variables[str(col_number + 2)] = f"C_{name}_CH"
909
+ col_number += 3
910
+ else:
911
+ conn["CostVar_index"] = {
912
+ "T": col_number,
913
+ "M": col_number + 1
914
+ }
915
+ self.variables[str(col_number)] = f"C_{name}_T"
916
+ self.variables[str(col_number + 1)] = f"C_{name}_M"
917
+ col_number += 2
918
+ # Check if this connection's target is a dissipative component.
919
+ target = conn.get("target_component")
920
+ if target in valid_components:
921
+ comp = self.exergy_analysis.components.get(target)
922
+ if comp is not None and getattr(comp, "is_dissipative", False):
923
+ # Add an extra index for the dissipative cost difference.
924
+ conn["CostVar_index"]["dissipative"] = col_number
925
+ self.variables[str(col_number)] = "dissipative"
926
+ col_number += 1
927
+ # For non-material streams (e.g., heat, power), assign one index.
928
+ elif kind in ("heat", "power"):
929
+ conn["CostVar_index"] = {"exergy": col_number}
930
+ self.variables[str(col_number)] = f"C_{name}_TOT"
931
+ col_number += 1
932
+
933
+ # Store the total number of cost variables for later use.
934
+ self.num_variables = col_number
935
+
936
+ def assign_user_costs(self, Exe_Eco_Costs):
937
+ """
938
+ Assign component and connection costs from user input dictionary.
939
+
940
+ Parameters
941
+ ----------
942
+ Exe_Eco_Costs : dict
943
+ Dictionary containing cost assignments for components and connections.
944
+ Format for components: "<component_name>_Z": cost_value [currency/h]
945
+ Format for connections: "<connection_name>_c": cost_value [currency/GJ]
946
+
947
+ Raises
948
+ ------
949
+ ValueError
950
+ If a component cost is missing or if a required input connection cost is not provided.
951
+
952
+ Notes
953
+ -----
954
+ Component costs are converted from [currency/h] to [currency/s].
955
+ Connection costs are converted from [currency/GJ] to [currency/J].
956
+ Material connections receive c_T, c_M, c_CH cost breakdowns.
957
+ Heat and power connections receive only c_TOT values.
958
+ """
959
+ # --- Component Costs ---
960
+ for comp_name, comp in self.components.items():
961
+ if isinstance(comp, CycleCloser):
962
+ continue
963
+ else:
964
+ cost_key = f"{comp_name}_Z"
965
+ if cost_key in Exe_Eco_Costs:
966
+ comp.Z_costs = Exe_Eco_Costs[cost_key] / 3600 # Convert currency/h to currency/s
967
+ else:
968
+ raise ValueError(f"Cost for component '{comp_name}' is mandatory but not provided in Exe_Eco_Costs.")
969
+
970
+ # --- Connection Costs ---
971
+ accepted_kinds = {"material", "heat", "power"}
972
+ for conn_name, conn in self.connections.items():
973
+ kind = conn.get("kind", "material")
974
+
975
+ # Only consider valid connection types
976
+ if kind not in accepted_kinds:
977
+ continue
978
+
979
+ cost_key = f"{conn_name}_c"
980
+
981
+ # Check if the connection is an input (but also not an output)
982
+ is_input = not conn.get("source_component") and conn.get("target_component")
983
+
984
+ # For input connections (except for power connections) a cost is mandatory.
985
+ if is_input and kind != "power" and cost_key not in Exe_Eco_Costs:
986
+ raise ValueError(f"Cost for input connection '{conn_name}' is mandatory but not provided in Exe_Eco_Costs.")
987
+
988
+ # Assign cost if provided.
989
+ if cost_key in Exe_Eco_Costs:
990
+ # Convert cost from currency/GJ to currency/J.
991
+ c_TOT = Exe_Eco_Costs[cost_key] * 1e-9
992
+ conn["c_TOT"] = c_TOT
993
+
994
+ if kind == "material":
995
+ # Compute C_TOT based on exergy terms and mass flow.
996
+ conn["C_TOT"] = c_TOT * conn.get("E", 0)
997
+
998
+ # Assign cost breakdown for material streams.
999
+ if self.chemical_exergy_enabled:
1000
+ exergy_terms = {"T": "e_T", "M": "e_M", "CH": "e_CH"}
1001
+ else:
1002
+ exergy_terms = {"T": "e_T", "M": "e_M"}
1003
+ for label, exergy_key in exergy_terms.items():
1004
+ conn[f"c_{label}"] = c_TOT
1005
+ conn[f"C_{label}"] = c_TOT * conn.get(exergy_key, 0) * conn.get("m", 0)
1006
+
1007
+ elif kind in {"heat", "power"}:
1008
+ # Ensure energy flow "E" is present before computing cost.
1009
+ if "E" not in conn:
1010
+ raise ValueError(f"Energy flow 'E' is missing for {kind} connection '{conn_name}'.")
1011
+
1012
+ # Assign only the total cost for heat and power streams.
1013
+ conn["C_TOT"] = c_TOT * conn["E"]
1014
+
1015
+
1016
+ def construct_matrix(self, Tamb):
1017
+ """
1018
+ Construct the exergoeconomic cost matrix and vector.
1019
+
1020
+ Parameters
1021
+ ----------
1022
+ Tamb : float
1023
+ Ambient temperature in Kelvin.
1024
+
1025
+ Returns
1026
+ -------
1027
+ tuple
1028
+ A tuple containing:
1029
+ - A: numpy.ndarray - The coefficient matrix for the linear equation system
1030
+ - b: numpy.ndarray - The right-hand side vector for the linear equation system
1031
+
1032
+ Notes
1033
+ -----
1034
+ This method constructs a system of linear equations that includes:
1035
+ 1. Cost balance equations for each productive component
1036
+ 2. Equations for inlet streams to fix their costs based on provided values
1037
+ 3. Auxiliary equations for power flows
1038
+ 4. Custom auxiliary equations from each component
1039
+ 5. Special equations for dissipative components
1040
+ """
1041
+ num_vars = self.num_variables
1042
+ A = np.zeros((num_vars, num_vars))
1043
+ b = np.zeros(num_vars)
1044
+ counter = 0
1045
+
1046
+ # Filter out CycleCloser instances, keeping the component objects.
1047
+ valid_components = [comp for comp in self.components.values() if not isinstance(comp, CycleCloser)]
1048
+ # Create a set of valid component names for cost balance comparisons.
1049
+ valid_component_names = {comp.name for comp in valid_components}
1050
+
1051
+ # 1. Cost balance equations for productive components.
1052
+ for comp in valid_components:
1053
+ if not getattr(comp, "is_dissipative", False):
1054
+ # Assign the row index for the cost balance equation to this component.
1055
+ comp.exergy_cost_line = counter
1056
+ for conn in self.connections.values():
1057
+ # Check if the connection is linked to a valid component.
1058
+ # If the connection's target is the component, it is an inlet (add +1).
1059
+ if conn.get("target_component") == comp.name:
1060
+ for key, col in conn["CostVar_index"].items():
1061
+ A[counter, col] = 1 # Incoming costs
1062
+ # If the connection's source is the component, it is an outlet (subtract -1).
1063
+ elif conn.get("source_component") == comp.name:
1064
+ for key, col in conn["CostVar_index"].items():
1065
+ A[counter, col] = -1 # Outgoing costs
1066
+ self.equations[counter] = f"Z_costs_{comp.name}" # Store the equation name
1067
+
1068
+ # For productive components: C_in - C_out = -Z_costs.
1069
+ if getattr(comp, "is_dissipative", False):
1070
+ continue
1071
+ else:
1072
+ b[counter] = -getattr(comp, "Z_costs", 1)
1073
+ counter += 1
1074
+
1075
+ # 2. Inlet stream equations.
1076
+ # Gather all power connections.
1077
+ power_conns = [conn for conn in self.connections.values() if conn.get("kind") == "power"]
1078
+ # Set the flag: if any power connection has NO target component, then there is an outlet.
1079
+ has_power_outlet = any(conn.get("target_component") is None for conn in power_conns)
1080
+
1081
+ for name, conn in self.connections.items():
1082
+ # A connection is treated as an inlet if its source_component is missing or not part of the system
1083
+ # and its target_component is among the valid components.
1084
+ if (conn.get("source_component") is None or conn.get("source_component") not in self.components) \
1085
+ and (conn.get("target_component") in valid_component_names):
1086
+ kind = conn.get("kind", "material")
1087
+ if kind == "material":
1088
+ if self.chemical_exergy_enabled:
1089
+ exergy_terms = ["T", "M", "CH"]
1090
+ else:
1091
+ exergy_terms = ["T", "M"]
1092
+ for label in exergy_terms:
1093
+ idx = conn["CostVar_index"][label]
1094
+ A[counter, idx] = 1 # Fix the cost variable.
1095
+ b[counter] = conn.get(f"C_{label}", conn.get("C_TOT", 0))
1096
+ self.equations[counter] = f"boundary_stream_costs_{name}_{label}"
1097
+ counter += 1
1098
+ elif kind == "heat":
1099
+ idx = conn["CostVar_index"]["exergy"]
1100
+ A[counter, idx] = 1
1101
+ b[counter] = conn.get("C_TOT", 0)
1102
+ self.equations[counter] = f"boundary_stream_costs_{name}_TOT"
1103
+ counter += 1
1104
+ elif kind == "power":
1105
+ if not has_power_outlet:
1106
+ # Skip this connection if the user did not define a cost (i.e. C_TOT is missing or zero).
1107
+ if not conn.get("C_TOT"):
1108
+ continue
1109
+ idx = conn["CostVar_index"]["exergy"]
1110
+ A[counter, idx] = 1
1111
+ b[counter] = conn.get("C_TOT", 0)
1112
+ self.equations[counter] = f"boundary_stream_costs_{name}_TOT"
1113
+ counter += 1
1114
+ else:
1115
+ continue
1116
+
1117
+ # 3. Auxiliary equations for the equality of the specific costs
1118
+ # of all power flows at the input or output of the system.
1119
+ power_conns = [conn for conn in self.connections.values()
1120
+ if conn.get("kind") == "power" and
1121
+ (conn.get("source_component") not in valid_component_names or conn.get("target_component") not in valid_component_names) and not
1122
+ (conn.get("source_component") not in valid_component_names and conn.get("target_component") not in valid_component_names)]
1123
+
1124
+ # Only add auxiliary equations if there is more than one power connection.
1125
+ if len(power_conns) > 1:
1126
+ # Choose the first connection as reference.
1127
+ ref = power_conns[0]
1128
+ ref_idx = ref["CostVar_index"]["exergy"]
1129
+ for conn in power_conns[1:]:
1130
+ cur_idx = conn["CostVar_index"]["exergy"]
1131
+ A[counter, ref_idx] = 1 / ref["E"] if ref["E"] != 0 else 1
1132
+ A[counter, cur_idx] = -1 / conn["E"] if conn["E"] != 0 else -1
1133
+ b[counter] = 0
1134
+ self.equations[counter] = f"aux_power_eq_{ref['name']}_{conn['name']}"
1135
+ counter += 1
1136
+
1137
+ # 4. Auxiliary equations.
1138
+ # These equations are needed because we have more variables than components.
1139
+ # For each productive component call its auxiliary equation routine, if available.
1140
+ for comp in self.components.values():
1141
+ if getattr(comp, "is_dissipative", False):
1142
+ continue
1143
+ else:
1144
+ if hasattr(comp, "aux_eqs") and callable(comp.aux_eqs):
1145
+ # The aux_eqs function should accept the current matrix, vector, counter, and Tamb,
1146
+ # and return the updated (A, b, counter).
1147
+ A, b, counter, self.equations = comp.aux_eqs(A, b, counter, Tamb, self.equations, self.chemical_exergy_enabled)
1148
+ else:
1149
+ # If no auxiliary equations are provided.
1150
+ logging.warning(f"No auxiliary equations provided for component '{comp.name}'.")
1151
+
1152
+ # 5. Dissipative components:
1153
+ # Now, for each dissipative component, call its dis_eqs() method.
1154
+ # This will build an equation that integrates the dissipative cost difference (C_diff)
1155
+ # into the overall cost balance (i.e. it charges the component’s Z_costs accordingly).
1156
+ for comp in self.components.values():
1157
+ if getattr(comp, "is_dissipative", False):
1158
+ if hasattr(comp, "dis_eqs") and callable(comp.dis_eqs):
1159
+ # Let the component provide its own modifications for the cost matrix.
1160
+ A, b, counter, self.equations = comp.dis_eqs(A, b, counter, Tamb, self.equations, self.chemical_exergy_enabled, list(self.components.values()))
1161
+
1162
+ return A, b
1163
+
1164
+
1165
+ def solve_exergoeconomic_analysis(self, Tamb):
1166
+ """
1167
+ Solve the exergoeconomic cost balance equations and assign the results to connections and components.
1168
+
1169
+ Parameters
1170
+ ----------
1171
+ Tamb : float
1172
+ Ambient temperature in Kelvin.
1173
+
1174
+ Returns
1175
+ -------
1176
+ tuple
1177
+ (exergy_cost_matrix, exergy_cost_vector) - The coefficient matrix and right-hand side vector used
1178
+ in the linear equation system.
1179
+
1180
+ Raises
1181
+ ------
1182
+ ValueError
1183
+ If the exergoeconomic system is singular or if the cost balance is not satisfied.
1184
+
1185
+ Notes
1186
+ -----
1187
+ This method performs the following steps:
1188
+ 1. Constructs the exergoeconomic cost matrix
1189
+ 2. Solves the system of linear equations
1190
+ 3. Assigns cost solutions to connections
1191
+ 4. Calculates component exergoeconomic indicators
1192
+ 5. Distributes loss stream costs to product streams
1193
+ 6. Computes system-level cost variables
1194
+ """
1195
+ # Step 1: Construct the cost matrix
1196
+ exergy_cost_matrix, exergy_cost_vector = self.construct_matrix(Tamb)
1197
+
1198
+ # Step 2: Solve the system of equations
1199
+ try:
1200
+ C_solution = np.linalg.solve(exergy_cost_matrix, exergy_cost_vector)
1201
+ except np.linalg.LinAlgError:
1202
+ raise ValueError(f"Exergoeconomic system is singular and cannot be solved. "
1203
+ f"Provided equations: {len(self.equations)}, variables in system: {len(self.variables)}")
1204
+
1205
+ # Step 3: Assign solutions to connections
1206
+ for conn_name, conn in self.connections.items():
1207
+ is_part_of_the_system = conn.get("source_component") or conn.get("target_component")
1208
+ if not is_part_of_the_system:
1209
+ continue
1210
+ else:
1211
+ kind = conn.get("kind")
1212
+ if kind == "material":
1213
+ # Retrieve mass flow and specific exergy values
1214
+ m_val = conn.get("m", 1) # mass flow [kg/s]
1215
+ e_T = conn.get("e_T", 0) # thermal specific exergy [kJ/kg]
1216
+ e_M = conn.get("e_M", 0) # mechanical specific exergy [kJ/kg]
1217
+ E_T = m_val * e_T # thermal exergy flow [kW]
1218
+ E_M = m_val * e_M # mechanical exergy flow [kW]
1219
+
1220
+ conn["C_T"] = C_solution[conn["CostVar_index"]["T"]]
1221
+ conn["c_T"] = conn["C_T"] / E_T if E_T != 0 else np.nan
1222
+
1223
+ conn["C_M"] = C_solution[conn["CostVar_index"]["M"]]
1224
+ conn["c_M"] = conn["C_M"] / E_M if E_M != 0 else np.nan
1225
+
1226
+ conn["C_PH"] = conn["C_T"] + conn["C_M"]
1227
+ conn["c_PH"] = conn["C_PH"] / (E_T + E_M) if (E_T + E_M) != 0 else np.nan
1228
+
1229
+ if self.chemical_exergy_enabled:
1230
+ e_CH = conn.get("e_CH", 0) # chemical specific exergy [kJ/kg]
1231
+ E_CH = m_val * e_CH # chemical exergy flow [kW]
1232
+ conn["C_CH"] = C_solution[conn["CostVar_index"]["CH"]]
1233
+ conn["c_CH"] = conn["C_CH"] / E_CH if E_CH != 0 else np.nan
1234
+ conn["C_TOT"] = conn["C_T"] + conn["C_M"] + conn["C_CH"]
1235
+ total_E = E_T + E_M + E_CH
1236
+ conn["c_TOT"] = conn["C_TOT"] / total_E if total_E != 0 else np.nan
1237
+ else:
1238
+ conn["C_TOT"] = conn["C_T"] + conn["C_M"]
1239
+ total_E = E_T + E_M
1240
+ conn["c_TOT"] = conn["C_TOT"] / total_E if total_E != 0 else np.nan
1241
+ elif kind in {"heat", "power"}:
1242
+ conn["C_TOT"] = C_solution[conn["CostVar_index"]["exergy"]]
1243
+ conn["c_TOT"] = conn["C_TOT"] / conn.get("E", 1)
1244
+
1245
+ # Step 4: Assign C_P, C_F, C_D, and f values to components
1246
+ for comp in self.exergy_analysis.components.values():
1247
+ if hasattr(comp, "exergoeconomic_balance") and callable(comp.exergoeconomic_balance):
1248
+ comp.exergoeconomic_balance(self.exergy_analysis.Tamb)
1249
+
1250
+ # Step 5: Distribute the cost of loss streams to the product streams.
1251
+ # For each loss stream (provided in E_L_dict), its C_TOT is distributed among the product streams (in E_P_dict)
1252
+ # in proportion to their exergy (E). After the distribution the loss stream's C_TOT is set to zero.
1253
+ loss_streams = self.E_L_dict.get("inputs", [])
1254
+ product_streams = self.E_P_dict.get("inputs", [])
1255
+ for loss_name in loss_streams:
1256
+ loss_conn = self.connections.get(loss_name)
1257
+ if loss_conn is None:
1258
+ continue
1259
+ loss_cost = loss_conn.get("C_TOT", 0)
1260
+ # If there is no cost assigned to this loss stream, skip it.
1261
+ if not loss_cost:
1262
+ continue
1263
+ # Calculate the total exergy of the product streams.
1264
+ total_E = 0
1265
+ for prod_name in product_streams:
1266
+ prod_conn = self.connections.get(prod_name)
1267
+ if prod_conn is None:
1268
+ continue
1269
+ total_E += prod_conn.get("E", 0)
1270
+ # Avoid division by zero.
1271
+ if total_E == 0:
1272
+ continue
1273
+ # Distribute the loss cost to each product stream proportionally to its exergy.
1274
+ for prod_name in product_streams:
1275
+ prod_conn = self.connections.get(prod_name)
1276
+ if prod_conn is None:
1277
+ continue
1278
+ prod_E = prod_conn.get("E", 0)
1279
+ share = loss_cost * (prod_E / total_E)
1280
+ prod_conn["C_TOT"] = prod_conn.get("C_TOT", 0) + share
1281
+ prod_conn["c_TOT"] = prod_conn["C_TOT"] / prod_conn.get("E", 1)
1282
+ # The cost of the loss streams are not set to zero to show
1283
+ # them in the table, but they are attributed to the product streams.
1284
+
1285
+ # Step 6: Compute system-level cost variables using the E_F and E_P dictionaries.
1286
+ # Compute total fuel cost (C_F_total) from fuel streams.
1287
+ C_F_total = 0.0
1288
+ for conn_name in self.E_F_dict.get("inputs", []):
1289
+ conn = self.connections.get(conn_name, {})
1290
+ C_F_total += conn.get("C_TOT", 0)
1291
+ for conn_name in self.E_F_dict.get("outputs", []):
1292
+ conn = self.connections.get(conn_name, {})
1293
+ C_F_total -= conn.get("C_TOT", 0)
1294
+
1295
+ # Compute total product cost (C_P_total) from product streams.
1296
+ C_P_total = 0.0
1297
+ for conn_name in self.E_P_dict.get("inputs", []):
1298
+ conn = self.connections.get(conn_name, {})
1299
+ C_P_total += conn.get("C_TOT", 0)
1300
+ for conn_name in self.E_P_dict.get("outputs", []):
1301
+ conn = self.connections.get(conn_name, {})
1302
+ C_P_total -= conn.get("C_TOT", 0)
1303
+
1304
+ # The total loss cost is assigned to the product already, so we don't need to consider it here.
1305
+
1306
+ # Compute the sum of all Z costs (Z_total) from all components except CycleCloser.
1307
+ Z_total = 0.0
1308
+ for comp in self.exergy_analysis.components.values():
1309
+ # Sum Z_costs from all non-CycleCloser components, converting from currency/s to currency/h.
1310
+ if comp.__class__.__name__ != "CycleCloser":
1311
+ Z_total += getattr(comp, "Z_costs", 0)
1312
+
1313
+ # convert the costs to currency/h
1314
+ C_F_total *= 3600
1315
+ C_P_total *= 3600
1316
+ Z_total *= 3600
1317
+
1318
+ # Store the system-level costs in the exergy analysis instance.
1319
+ self.system_costs = {
1320
+ "C_F": float(C_F_total),
1321
+ "C_P": float(C_P_total),
1322
+ "Z": float(Z_total)
1323
+ }
1324
+
1325
+ # Check cost balance and raise error if violated
1326
+ if abs(self.system_costs["C_P"] - self.system_costs["C_F"] - self.system_costs["Z"]) > 1e-4:
1327
+ raise ValueError(
1328
+ f"Exergoeconomic cost balance not satisfied: C_P ({self.system_costs['C_P']:.6f}) ≠ "
1329
+ f"C_F ({self.system_costs['C_F']:.6f}) + Z ({self.system_costs['Z']:.6f})"
1330
+ )
1331
+
1332
+ return exergy_cost_matrix, exergy_cost_vector
1333
+
1334
+ def run(self, Exe_Eco_Costs, Tamb):
1335
+ """
1336
+ Execute the full exergoeconomic analysis.
1337
+
1338
+ Parameters
1339
+ ----------
1340
+ Exe_Eco_Costs : dict
1341
+ Dictionary containing cost assignments for components and connections.
1342
+ Format for components: "<component_name>_Z": cost_value [currency/h]
1343
+ Format for connections: "<connection_name>_c": cost_value [currency/GJ]
1344
+ Tamb : float
1345
+ Ambient temperature in Kelvin.
1346
+
1347
+ Notes
1348
+ -----
1349
+ This method performs the complete exergoeconomic analysis by:
1350
+ 1. Initializing cost variables for all components and streams
1351
+ 2. Assigning user-defined costs to components and boundary streams
1352
+ 3. Solving the system of exergoeconomic equations
1353
+ """
1354
+ self.initialize_cost_variables()
1355
+ self.assign_user_costs(Exe_Eco_Costs)
1356
+ self.solve_exergoeconomic_analysis(Tamb)
1357
+ logging.info(f"Exergoeconomic analysis completed successfully.")
1358
+
1359
+
1360
+ def exergoeconomic_results(self, print_results=True):
1361
+ """
1362
+ Displays tables of exergoeconomic analysis results with columns for costs and economic parameters for each component,
1363
+ and additional cost information for material and non-material connections.
1364
+
1365
+ Parameters
1366
+ ----------
1367
+ print_results : bool, optional
1368
+ If True, prints the results as tables in the console (default is True).
1369
+
1370
+ Returns
1371
+ -------
1372
+ tuple of pandas.DataFrame
1373
+ (df_component_results, df_material_connection_results_part1, df_material_connection_results_part2, df_non_material_connection_results)
1374
+ with the exergoeconomic analysis results.
1375
+ """
1376
+ # Retrieve the base exergy results without printing them
1377
+ df_comp, df_mat, df_non_mat = self.exergy_analysis.exergy_results(print_results=False)
1378
+
1379
+ # -------------------------
1380
+ # Add new cost columns to the component results table.
1381
+ # We assume that each component (except CycleCloser, which is already excluded)
1382
+ # has attributes: C_F, C_P, C_D, and Z_cost (all in currency/s), which we convert to currency/h.
1383
+ C_F_list = []
1384
+ C_P_list = []
1385
+ C_D_list = []
1386
+ Z_cost_list = []
1387
+ r_list = []
1388
+ f_list = []
1389
+
1390
+ # Iterate over the component DataFrame rows. The "Component" column contains the key.
1391
+ for idx, row in df_comp.iterrows():
1392
+ comp_name = row["Component"]
1393
+ if comp_name != "TOT":
1394
+ comp = self.components.get(comp_name, None)
1395
+ if comp is not None:
1396
+ C_F_list.append(getattr(comp, "C_F", 0) * 3600)
1397
+ C_P_list.append(getattr(comp, "C_P", 0) * 3600)
1398
+ C_D_list.append(getattr(comp, "C_D", 0) * 3600)
1399
+ Z_cost_list.append(getattr(comp, "Z_costs", 0) * 3600)
1400
+ f_list.append(getattr(comp, "f", 0) * 100)
1401
+ r_list.append(getattr(comp, "r", 0) * 100)
1402
+ else:
1403
+ C_F_list.append(np.nan)
1404
+ C_P_list.append(np.nan)
1405
+ C_D_list.append(np.nan)
1406
+ Z_cost_list.append(np.nan)
1407
+ f_list.append(np.nan)
1408
+ r_list.append(np.nan)
1409
+ else:
1410
+ # We'll update the TOT row using system-level values later.
1411
+ C_F_list.append(np.nan)
1412
+ C_P_list.append(np.nan)
1413
+ C_D_list.append(np.nan)
1414
+ Z_cost_list.append(np.nan)
1415
+ f_list.append(np.nan)
1416
+ r_list.append(np.nan)
1417
+
1418
+ # Add the new columns to the component DataFrame.
1419
+ df_comp[f"C_F [{self.currency}/h]"] = C_F_list
1420
+ df_comp[f"C_P [{self.currency}/h]"] = C_P_list
1421
+ df_comp[f"C_D [{self.currency}/h]"] = C_D_list
1422
+ df_comp[f"Z [{self.currency}/h]"] = Z_cost_list
1423
+ df_comp[f"C_D+Z [{self.currency}/h]"] = df_comp[f"C_D [{self.currency}/h]"] + df_comp[f"Z [{self.currency}/h]"]
1424
+ df_comp[f"f [%]"] = f_list
1425
+ df_comp[f"r [%]"] = r_list
1426
+
1427
+ # Update the TOT row with system-level values using .loc.
1428
+ df_comp.loc["TOT", f"C_F [{self.currency}/h]"] = self.system_costs.get("C_F", np.nan)
1429
+ df_comp.loc["TOT", f"C_P [{self.currency}/h]"] = self.system_costs.get("C_P", np.nan)
1430
+ df_comp.loc["TOT", f"Z [{self.currency}/h]"] = self.system_costs.get("Z", np.nan)
1431
+ df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] = (
1432
+ df_comp.loc["TOT", f"C_D [{self.currency}/h]"] +
1433
+ df_comp.loc["TOT", f"Z [{self.currency}/h]"]
1434
+ )
1435
+
1436
+ df_comp[f"c_F [{self.currency}/GJ]"] = df_comp[f"C_F [{self.currency}/h]"] / df_comp["E_F [kW]"] * 1e6 / 3600
1437
+ df_comp[f"c_P [{self.currency}/GJ]"] = df_comp[f"C_P [{self.currency}/h]"] / df_comp["E_P [kW]"] * 1e6 / 3600
1438
+
1439
+ df_comp.loc["TOT", f"C_D [{self.currency}/h]"] = df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"] * df_comp.loc["TOT", f"E_D [kW]"] / 1e6 * 3600
1440
+ df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] = df_comp.loc["TOT", f"C_D [{self.currency}/h]"] + df_comp.loc["TOT", f"Z [{self.currency}/h]"]
1441
+ df_comp.loc["TOT", f"f [%]"] = df_comp.loc["TOT", f"Z [{self.currency}/h]"] / df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] * 100
1442
+ df_comp.loc["TOT", f"r [%]"] = ((df_comp.loc["TOT", f"c_P [{self.currency}/GJ]"] - df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"]) /
1443
+ df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"]) * 100
1444
+
1445
+ # -------------------------
1446
+ # Add cost columns to material connections.
1447
+ # -------------------------
1448
+ # Uppercase cost columns (in currency/h)
1449
+ C_T_list = []
1450
+ C_M_list = []
1451
+ C_CH_list = []
1452
+ C_TOT_list = []
1453
+ # Lowercase cost columns (in GJ/{currency})
1454
+ c_T_list = []
1455
+ c_M_list = []
1456
+ c_CH_list = []
1457
+ c_TOT_list = []
1458
+
1459
+ for idx, row in df_mat.iterrows():
1460
+ conn_name = row['Connection']
1461
+ conn_data = self.connections.get(conn_name, {})
1462
+ kind = conn_data.get("kind", None)
1463
+ if kind == "material":
1464
+ C_T = conn_data.get("C_T", None)
1465
+ C_M = conn_data.get("C_M", None)
1466
+ C_CH = conn_data.get("C_CH", None)
1467
+ C_TOT = conn_data.get("C_TOT", None)
1468
+ c_T = conn_data.get("c_T", None)
1469
+ c_M = conn_data.get("c_M", None)
1470
+ c_CH = conn_data.get("c_CH", None)
1471
+ c_TOT = conn_data.get("c_TOT", None)
1472
+ C_T_list.append(C_T * 3600 if C_T is not None else None)
1473
+ C_M_list.append(C_M * 3600 if C_M is not None else None)
1474
+ C_CH_list.append(C_CH * 3600 if C_CH is not None else None)
1475
+ C_TOT_list.append(C_TOT * 3600 if C_TOT is not None else None)
1476
+ c_T_list.append(c_T * 1e9 if c_T is not None else None)
1477
+ c_M_list.append(c_M * 1e9 if c_M is not None else None)
1478
+ c_CH_list.append(c_CH * 1e9 if c_CH is not None else None)
1479
+ c_TOT_list.append(c_TOT * 1e9 if c_TOT is not None else None)
1480
+ elif kind in {"heat", "power"}:
1481
+ # For non-material streams in the material table, only C^TOT is defined.
1482
+ C_T_list.append(np.nan)
1483
+ C_M_list.append(np.nan)
1484
+ C_CH_list.append(np.nan)
1485
+ c_T_list.append(np.nan)
1486
+ c_M_list.append(np.nan)
1487
+ c_CH_list.append(np.nan)
1488
+ C_TOT = conn_data.get("C_TOT", None)
1489
+ C_TOT_list.append(C_TOT * 3600 if C_TOT is not None else None)
1490
+ c_TOT = conn_data.get("C_TOT", None)
1491
+ c_TOT_list.append(c_TOT * 1e9 if c_TOT is not None else None)
1492
+ else:
1493
+ C_T_list.append(np.nan)
1494
+ C_M_list.append(np.nan)
1495
+ C_CH_list.append(np.nan)
1496
+ C_TOT_list.append(np.nan)
1497
+ c_T_list.append(np.nan)
1498
+ c_M_list.append(np.nan)
1499
+ c_CH_list.append(np.nan)
1500
+ c_TOT_list.append(np.nan)
1501
+
1502
+ df_mat[f"C^T [{self.currency}/h]"] = C_T_list
1503
+ df_mat[f"C^M [{self.currency}/h]"] = C_M_list
1504
+ df_mat[f"C^CH [{self.currency}/h]"] = C_CH_list
1505
+ df_mat[f"C^TOT [{self.currency}/h]"] = C_TOT_list
1506
+ df_mat[f"c^T [GJ/{self.currency}]"] = c_T_list
1507
+ df_mat[f"c^M [GJ/{self.currency}]"] = c_M_list
1508
+ df_mat[f"c^CH [GJ/{self.currency}]"] = c_CH_list
1509
+ df_mat[f"c^TOT [GJ/{self.currency}]"] = c_TOT_list
1510
+
1511
+ # -------------------------
1512
+ # Add cost columns to non-material connections.
1513
+ # -------------------------
1514
+ C_TOT_non_mat = []
1515
+ c_TOT_non_mat = []
1516
+ for idx, row in df_non_mat.iterrows():
1517
+ conn_name = row["Connection"]
1518
+ conn_data = self.connections.get(conn_name, {})
1519
+ C_TOT = conn_data.get("C_TOT", None)
1520
+ C_TOT_non_mat.append(C_TOT * 3600 if C_TOT is not None else None)
1521
+ c_TOT = conn_data.get("c_TOT", None)
1522
+ c_TOT_non_mat.append(c_TOT * 1e9 if c_TOT is not None else None)
1523
+ df_non_mat[f"C^TOT [{self.currency}/h]"] = C_TOT_non_mat
1524
+ df_non_mat[f"c^TOT [GJ/{self.currency}]"] = c_TOT_non_mat
1525
+
1526
+ # -------------------------
1527
+ # Split the material connections into two tables according to your specifications.
1528
+ # -------------------------
1529
+ # df_mat1: Columns from mass flow until e^CH.
1530
+ df_mat1 = df_mat[[
1531
+ "Connection",
1532
+ "m [kg/s]",
1533
+ "T [°C]",
1534
+ "p [bar]",
1535
+ "h [kJ/kg]",
1536
+ "s [J/kgK]",
1537
+ "E [kW]",
1538
+ "e^PH [kJ/kg]",
1539
+ "e^T [kJ/kg]",
1540
+ "e^M [kJ/kg]",
1541
+ "e^CH [kJ/kg]"
1542
+ ]].copy()
1543
+
1544
+ # df_mat2: Columns from E onward, plus the uppercase and lowercase cost columns.
1545
+ df_mat2 = df_mat[[
1546
+ "Connection",
1547
+ "E [kW]",
1548
+ "e^PH [kJ/kg]",
1549
+ "e^T [kJ/kg]",
1550
+ "e^M [kJ/kg]",
1551
+ "e^CH [kJ/kg]",
1552
+ f"C^T [{self.currency}/h]",
1553
+ f"C^M [{self.currency}/h]",
1554
+ f"C^CH [{self.currency}/h]",
1555
+ f"C^TOT [{self.currency}/h]",
1556
+ f"c^T [GJ/{self.currency}]",
1557
+ f"c^M [GJ/{self.currency}]",
1558
+ f"c^CH [GJ/{self.currency}]",
1559
+ f"c^TOT [GJ/{self.currency}]"
1560
+ ]].copy()
1561
+
1562
+ # Remove any columns that contain only NaN values from df_mat1, df_mat2, and df_non_mat.
1563
+ df_mat1.dropna(axis=1, how='all', inplace=True)
1564
+ df_mat2.dropna(axis=1, how='all', inplace=True)
1565
+ df_non_mat.dropna(axis=1, how='all', inplace=True)
1566
+
1567
+ # -------------------------
1568
+ # Print the four tables if requested.
1569
+ # -------------------------
1570
+ if print_results:
1571
+ print("\nExergoeconomic Analysis - Component Results:")
1572
+ print(tabulate(df_comp.reset_index(drop=True), headers="keys", tablefmt="psql", floatfmt=".3f"))
1573
+ print("\nExergoeconomic Analysis - Material Connection Results (exergy data):")
1574
+ print(tabulate(df_mat1.reset_index(drop=True), headers="keys", tablefmt="psql", floatfmt=".3f"))
1575
+ print("\nExergoeconomic Analysis - Material Connection Results (cost data):")
1576
+ print(tabulate(df_mat2.reset_index(drop=True), headers="keys", tablefmt="psql", floatfmt=".3f"))
1577
+ print("\nExergoeconomic Analysis - Non-Material Connection Results:")
1578
+ print(tabulate(df_non_mat.reset_index(drop=True), headers="keys", tablefmt="psql", floatfmt=".3f"))
1579
+
1580
+ return df_comp, df_mat1, df_mat2, df_non_mat
1581
+
1582
+
1583
+ class EconomicAnalysis:
1584
+ """
1585
+ Perform economic analysis of a power plant using the total revenue requirement method.
1586
+
1587
+ Parameters
1588
+ ----------
1589
+ pars : dict
1590
+ Dictionary containing the following keys:
1591
+ - tau: Full load hours of the plant (hours/year)
1592
+ - i_eff: Effective rate of return (yearly based)
1593
+ - n: Lifetime of the plant (years)
1594
+ - r_n: Nominal escalation rate (yearly based)
1595
+
1596
+ Attributes
1597
+ ----------
1598
+ tau : float
1599
+ Full load hours of the plant (hours/year).
1600
+ i_eff : float
1601
+ Effective rate of return (yearly based).
1602
+ n : int
1603
+ Lifetime of the plant (years).
1604
+ r_n : float
1605
+ Nominal escalation rate (yearly based).
1606
+ """
1607
+
1608
+ def __init__(self, pars):
1609
+ """
1610
+ Initialize the EconomicAnalysis with plant parameters provided in a dictionary.
1611
+
1612
+ Parameters
1613
+ ----------
1614
+ pars : dict
1615
+ Dictionary containing the following keys:
1616
+ - tau: Full load hours of the plant (hours/year)
1617
+ - i_eff: Effective rate of return (yearly based)
1618
+ - n: Lifetime of the plant (years)
1619
+ - r_n: Nominal escalation rate (yearly based)
1620
+ """
1621
+ self.tau = pars['tau']
1622
+ self.i_eff = pars['i_eff']
1623
+ self.n = pars['n']
1624
+ self.r_n = pars['r_n']
1625
+
1626
+ def compute_crf(self):
1627
+ """
1628
+ Compute the Capital Recovery Factor (CRF) using the effective rate of return.
1629
+
1630
+ Returns
1631
+ -------
1632
+ float
1633
+ The capital recovery factor.
1634
+
1635
+ Notes
1636
+ -----
1637
+ CRF = i_eff * (1 + i_eff)**n / ((1 + i_eff)**n - 1)
1638
+ """
1639
+ return self.i_eff * (1 + self.i_eff)**self.n / ((1 + self.i_eff)**self.n - 1)
1640
+
1641
+ def compute_celf(self):
1642
+ """
1643
+ Compute the Cost Escalation Levelization Factor (CELF) for repeating expenditures.
1644
+
1645
+ Returns
1646
+ -------
1647
+ float
1648
+ The cost escalation levelization factor.
1649
+
1650
+ Notes
1651
+ -----
1652
+ k = (1 + r_n) / (1 + i_eff)
1653
+ CELF = ((1 - k**n) / (1 - k)) * CRF
1654
+ """
1655
+ k = (1 + self.r_n) / (1 + self.i_eff)
1656
+ return (1 - k**self.n) / (1 - k) * self.compute_crf()
1657
+
1658
+ def compute_levelized_investment_cost(self, total_PEC):
1659
+ """
1660
+ Compute the levelized investment cost (annualized investment cost).
1661
+
1662
+ Parameters
1663
+ ----------
1664
+ total_PEC : float
1665
+ Total purchasing equipment cost (PEC) across all components.
1666
+
1667
+ Returns
1668
+ -------
1669
+ float
1670
+ Levelized investment cost (currency/year).
1671
+ """
1672
+ return total_PEC * self.compute_crf()
1673
+
1674
+ def compute_component_costs(self, PEC_list, OMC_relative):
1675
+ """
1676
+ Compute the cost rates for each component.
1677
+
1678
+ Parameters
1679
+ ----------
1680
+ PEC_list : list of float
1681
+ The purchasing equipment cost (PEC) of each component (in currency).
1682
+ OMC_relative : list of float
1683
+ For each component, the first-year OM cost as a fraction of its PEC.
1684
+
1685
+ Returns
1686
+ -------
1687
+ tuple
1688
+ (Z_CC, Z_OM, Z_total) where:
1689
+ - Z_CC: List of investment cost rates per component (currency/hour)
1690
+ - Z_OM: List of operating and maintenance cost rates per component (currency/hour)
1691
+ - Z_total: List of total cost rates per component (currency/hour)
1692
+ """
1693
+ total_PEC = sum(PEC_list)
1694
+ # Levelize total investment cost and allocate proportionally.
1695
+ levelized_investment_cost = self.compute_levelized_investment_cost(total_PEC)
1696
+ Z_CC = [(levelized_investment_cost * pec / total_PEC) / self.tau for pec in PEC_list]
1697
+
1698
+ # Compute first-year OMC for each component as a fraction of PEC.
1699
+ first_year_OMC = [frac * pec for frac, pec in zip(OMC_relative, PEC_list)]
1700
+ total_first_year_OMC = sum(first_year_OMC)
1701
+
1702
+ # Levelize the total operating and maintenance cost.
1703
+ celf_value = self.compute_celf()
1704
+ levelized_om_cost = total_first_year_OMC * celf_value
1705
+
1706
+ # Allocate the levelized OM cost to each component in proportion to its PEC.
1707
+ Z_OM = [(levelized_om_cost * pec / total_PEC) / self.tau for pec in PEC_list]
1708
+
1709
+ # Total cost rate per component.
1710
+ Z_total = [zcc + zom for zcc, zom in zip(Z_CC, Z_OM)]
1711
+ return Z_CC, Z_OM, Z_total