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.
- exerpy/__init__.py +12 -0
- exerpy/analyses.py +1711 -0
- exerpy/components/__init__.py +16 -0
- exerpy/components/combustion/__init__.py +0 -0
- exerpy/components/combustion/base.py +248 -0
- exerpy/components/component.py +126 -0
- exerpy/components/heat_exchanger/__init__.py +0 -0
- exerpy/components/heat_exchanger/base.py +449 -0
- exerpy/components/heat_exchanger/condenser.py +323 -0
- exerpy/components/heat_exchanger/simple.py +358 -0
- exerpy/components/heat_exchanger/steam_generator.py +264 -0
- exerpy/components/helpers/__init__.py +0 -0
- exerpy/components/helpers/cycle_closer.py +104 -0
- exerpy/components/nodes/__init__.py +0 -0
- exerpy/components/nodes/deaerator.py +318 -0
- exerpy/components/nodes/drum.py +164 -0
- exerpy/components/nodes/flash_tank.py +89 -0
- exerpy/components/nodes/mixer.py +332 -0
- exerpy/components/piping/__init__.py +0 -0
- exerpy/components/piping/valve.py +394 -0
- exerpy/components/power_machines/__init__.py +0 -0
- exerpy/components/power_machines/generator.py +168 -0
- exerpy/components/power_machines/motor.py +173 -0
- exerpy/components/turbomachinery/__init__.py +0 -0
- exerpy/components/turbomachinery/compressor.py +318 -0
- exerpy/components/turbomachinery/pump.py +310 -0
- exerpy/components/turbomachinery/turbine.py +351 -0
- exerpy/data/Ahrendts.json +90 -0
- exerpy/functions.py +637 -0
- exerpy/parser/__init__.py +0 -0
- exerpy/parser/from_aspen/__init__.py +0 -0
- exerpy/parser/from_aspen/aspen_config.py +61 -0
- exerpy/parser/from_aspen/aspen_parser.py +721 -0
- exerpy/parser/from_ebsilon/__init__.py +38 -0
- exerpy/parser/from_ebsilon/check_ebs_path.py +74 -0
- exerpy/parser/from_ebsilon/ebsilon_config.py +1055 -0
- exerpy/parser/from_ebsilon/ebsilon_functions.py +181 -0
- exerpy/parser/from_ebsilon/ebsilon_parser.py +660 -0
- exerpy/parser/from_ebsilon/utils.py +79 -0
- exerpy/parser/from_tespy/tespy_config.py +23 -0
- exerpy-0.0.1.dist-info/METADATA +158 -0
- exerpy-0.0.1.dist-info/RECORD +44 -0
- exerpy-0.0.1.dist-info/WHEEL +4 -0
- 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
|