exerpy 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exerpy/__init__.py +2 -4
- exerpy/analyses.py +849 -304
- exerpy/components/__init__.py +3 -0
- exerpy/components/combustion/base.py +53 -35
- exerpy/components/component.py +8 -8
- exerpy/components/heat_exchanger/base.py +188 -121
- exerpy/components/heat_exchanger/condenser.py +98 -62
- exerpy/components/heat_exchanger/simple.py +237 -137
- exerpy/components/heat_exchanger/steam_generator.py +46 -41
- exerpy/components/helpers/cycle_closer.py +61 -34
- exerpy/components/helpers/power_bus.py +117 -0
- exerpy/components/nodes/deaerator.py +176 -58
- exerpy/components/nodes/drum.py +50 -39
- exerpy/components/nodes/flash_tank.py +218 -43
- exerpy/components/nodes/mixer.py +249 -69
- exerpy/components/nodes/splitter.py +173 -0
- exerpy/components/nodes/storage.py +130 -0
- exerpy/components/piping/valve.py +311 -115
- exerpy/components/power_machines/generator.py +105 -38
- exerpy/components/power_machines/motor.py +111 -39
- exerpy/components/turbomachinery/compressor.py +214 -68
- exerpy/components/turbomachinery/pump.py +215 -68
- exerpy/components/turbomachinery/turbine.py +182 -74
- exerpy/cost_estimation/__init__.py +65 -0
- exerpy/cost_estimation/turton.py +1260 -0
- exerpy/data/cost_correlations/cepci_index.json +135 -0
- exerpy/data/cost_correlations/component_mapping.json +450 -0
- exerpy/data/cost_correlations/material_factors.json +428 -0
- exerpy/data/cost_correlations/pressure_factors.json +206 -0
- exerpy/data/cost_correlations/turton2008.json +726 -0
- exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_tables.pdf +0 -0
- exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_theory.pdf +0 -0
- exerpy/functions.py +389 -264
- exerpy/parser/from_aspen/aspen_config.py +57 -48
- exerpy/parser/from_aspen/aspen_parser.py +373 -280
- exerpy/parser/from_ebsilon/__init__.py +2 -2
- exerpy/parser/from_ebsilon/check_ebs_path.py +15 -19
- exerpy/parser/from_ebsilon/ebsilon_config.py +328 -226
- exerpy/parser/from_ebsilon/ebsilon_functions.py +205 -38
- exerpy/parser/from_ebsilon/ebsilon_parser.py +392 -255
- exerpy/parser/from_ebsilon/utils.py +16 -11
- exerpy/parser/from_tespy/tespy_config.py +33 -1
- exerpy/parser/from_tespy/tespy_parser.py +151 -0
- {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/METADATA +43 -2
- exerpy-0.0.4.dist-info/RECORD +57 -0
- exerpy-0.0.2.dist-info/RECORD +0 -44
- {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/WHEEL +0 -0
- {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/licenses/LICENSE +0 -0
exerpy/analyses.py
CHANGED
|
@@ -2,14 +2,16 @@ import json
|
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
5
6
|
import numpy as np
|
|
6
7
|
import pandas as pd
|
|
7
8
|
from tabulate import tabulate
|
|
8
9
|
|
|
9
10
|
from .components.component import component_registry
|
|
10
11
|
from .components.helpers.cycle_closer import CycleCloser
|
|
11
|
-
from .
|
|
12
|
-
from .
|
|
12
|
+
from .components.helpers.power_bus import PowerBus
|
|
13
|
+
from .components.nodes.splitter import Splitter
|
|
14
|
+
from .functions import add_chemical_exergy, add_total_exergy_flow
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class ExergyAnalysis:
|
|
@@ -70,6 +72,10 @@ class ExergyAnalysis:
|
|
|
70
72
|
Creates an instance from a JSON file containing system data.
|
|
71
73
|
exergy_results(print_results=True)
|
|
72
74
|
Displays and returns tables of exergy analysis results.
|
|
75
|
+
plot_exergy_waterfall(title=None, figsize=(12, 10), exclude_components=None, show_plot=True)
|
|
76
|
+
Creates an exergy destruction waterfall diagram visualizing exergy flow through the system.
|
|
77
|
+
print_exergy_summary()
|
|
78
|
+
Prints a concise text summary of the exergy analysis results.
|
|
73
79
|
export_to_json(output_path)
|
|
74
80
|
Exports the model and analysis results to a JSON file.
|
|
75
81
|
_serialize()
|
|
@@ -106,7 +112,7 @@ class ExergyAnalysis:
|
|
|
106
112
|
self.components = _construct_components(component_data, connection_data, Tamb)
|
|
107
113
|
self.connections = connection_data
|
|
108
114
|
|
|
109
|
-
def analyse(self, E_F, E_P, E_L=
|
|
115
|
+
def analyse(self, E_F, E_P, E_L=None) -> None:
|
|
110
116
|
"""
|
|
111
117
|
Run the exergy analysis for the entire system and calculate overall exergy efficiency.
|
|
112
118
|
|
|
@@ -120,6 +126,8 @@ class ExergyAnalysis:
|
|
|
120
126
|
Dictionary containing input and output connections for loss exergy (default is {}).
|
|
121
127
|
"""
|
|
122
128
|
# Initialize class attributes for the exergy value of the total system
|
|
129
|
+
if E_L is None:
|
|
130
|
+
E_L = {}
|
|
123
131
|
self.E_F = 0.0
|
|
124
132
|
self.E_P = 0.0
|
|
125
133
|
self.E_L = 0.0
|
|
@@ -131,53 +139,38 @@ class ExergyAnalysis:
|
|
|
131
139
|
for connections in ex_flow.values():
|
|
132
140
|
for connection in connections:
|
|
133
141
|
if connection not in self.connections:
|
|
134
|
-
msg =
|
|
135
|
-
f"The connection {connection} is not part of the "
|
|
136
|
-
"plant's connections."
|
|
137
|
-
)
|
|
142
|
+
msg = f"The connection {connection} is not part of the " "plant's connections."
|
|
138
143
|
raise ValueError(msg)
|
|
139
144
|
|
|
140
145
|
# Calculate total fuel exergy (E_F) by summing up all specified input connections
|
|
141
146
|
if "inputs" in E_F:
|
|
142
147
|
self.E_F += sum(
|
|
143
|
-
self.connections[conn][
|
|
144
|
-
for conn in E_F["inputs"]
|
|
145
|
-
if self.connections[conn]['E'] is not None
|
|
148
|
+
self.connections[conn]["E"] for conn in E_F["inputs"] if self.connections[conn]["E"] is not None
|
|
146
149
|
)
|
|
147
150
|
|
|
148
151
|
if "outputs" in E_F:
|
|
149
152
|
self.E_F -= sum(
|
|
150
|
-
self.connections[conn][
|
|
151
|
-
for conn in E_F["outputs"]
|
|
152
|
-
if self.connections[conn]['E'] is not None
|
|
153
|
+
self.connections[conn]["E"] for conn in E_F["outputs"] if self.connections[conn]["E"] is not None
|
|
153
154
|
)
|
|
154
155
|
|
|
155
156
|
# Calculate total product exergy (E_P) by summing up all specified input and output connections
|
|
156
157
|
if "inputs" in E_P:
|
|
157
158
|
self.E_P += sum(
|
|
158
|
-
self.connections[conn][
|
|
159
|
-
for conn in E_P["inputs"]
|
|
160
|
-
if self.connections[conn]['E'] is not None
|
|
159
|
+
self.connections[conn]["E"] for conn in E_P["inputs"] if self.connections[conn]["E"] is not None
|
|
161
160
|
)
|
|
162
161
|
if "outputs" in E_P:
|
|
163
162
|
self.E_P -= sum(
|
|
164
|
-
self.connections[conn][
|
|
165
|
-
for conn in E_P["outputs"]
|
|
166
|
-
if self.connections[conn]['E'] is not None
|
|
163
|
+
self.connections[conn]["E"] for conn in E_P["outputs"] if self.connections[conn]["E"] is not None
|
|
167
164
|
)
|
|
168
165
|
|
|
169
166
|
# Calculate total loss exergy (E_L) by summing up all specified input and output connections
|
|
170
167
|
if "inputs" in E_L:
|
|
171
168
|
self.E_L += sum(
|
|
172
|
-
self.connections[conn][
|
|
173
|
-
for conn in E_L["inputs"]
|
|
174
|
-
if self.connections[conn]['E'] is not None
|
|
169
|
+
self.connections[conn]["E"] for conn in E_L["inputs"] if self.connections[conn]["E"] is not None
|
|
175
170
|
)
|
|
176
171
|
if "outputs" in E_L:
|
|
177
172
|
self.E_L -= sum(
|
|
178
|
-
self.connections[conn][
|
|
179
|
-
for conn in E_L["outputs"]
|
|
180
|
-
if self.connections[conn]['E'] is not None
|
|
173
|
+
self.connections[conn]["E"] for conn in E_L["outputs"] if self.connections[conn]["E"] is not None
|
|
181
174
|
)
|
|
182
175
|
|
|
183
176
|
# Calculate overall exergy efficiency epsilon = E_P / E_F
|
|
@@ -187,41 +180,50 @@ class ExergyAnalysis:
|
|
|
187
180
|
# The rest is counted as total exergy destruction with all components of the system
|
|
188
181
|
self.E_D = self.E_F - self.E_P - self.E_L
|
|
189
182
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
183
|
+
# Check for unaccounted connections in the system
|
|
184
|
+
self._check_unaccounted_system_conns()
|
|
185
|
+
|
|
186
|
+
eff_str = f"{self.epsilon:.2%}" if self.epsilon is not None else "N/A"
|
|
194
187
|
logging.info(
|
|
195
188
|
f"Overall exergy analysis completed: E_F = {self.E_F:.2f} kW, "
|
|
196
189
|
f"E_P = {self.E_P:.2f} kW, E_L = {self.E_L:.2f} kW, "
|
|
197
190
|
f"Efficiency = {eff_str}"
|
|
198
|
-
|
|
191
|
+
)
|
|
199
192
|
|
|
200
193
|
# Perform exergy balance for each individual component in the system
|
|
201
194
|
total_component_E_D = 0.0
|
|
202
|
-
for
|
|
195
|
+
for _component_name, component in self.components.items():
|
|
203
196
|
if component.__class__.__name__ == "CycleCloser":
|
|
204
197
|
continue
|
|
205
198
|
else:
|
|
206
199
|
# Calculate E_F, E_D, E_P
|
|
207
200
|
component.calc_exergy_balance(self.Tamb, self.pamb, self.split_physical_exergy)
|
|
201
|
+
|
|
202
|
+
# Update is_dissipative flag based on actual E_P value for Valve components
|
|
203
|
+
# This is needed because when split_physical_exergy=False, valves may become
|
|
204
|
+
# dissipative (E_P = nan) even if not initially marked as such based on temperatures
|
|
205
|
+
if component.__class__.__name__ == "Valve" and np.isnan(component.E_P):
|
|
206
|
+
component.is_dissipative = True
|
|
207
|
+
|
|
208
208
|
# Safely calculate y and y* avoiding division by zero
|
|
209
209
|
if self.E_F != 0:
|
|
210
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
|
|
211
|
+
component.y_star = component.E_D / self.E_D if component.E_D is not None else np.nan
|
|
212
212
|
else:
|
|
213
|
-
component.y =
|
|
214
|
-
component.y_star =
|
|
213
|
+
component.y = np.nan
|
|
214
|
+
component.y_star = np.nan
|
|
215
215
|
# Sum component destruction if available
|
|
216
|
-
if component.E_D is not
|
|
216
|
+
if component.E_D is not np.nan:
|
|
217
217
|
total_component_E_D += component.E_D
|
|
218
218
|
|
|
219
219
|
# Check if the sum of all component exergy destructions matches the overall system exergy destruction
|
|
220
220
|
if not np.isclose(total_component_E_D, self.E_D, rtol=1e-5):
|
|
221
|
-
logging.warning(
|
|
222
|
-
|
|
221
|
+
logging.warning(
|
|
222
|
+
f"Sum of component exergy destructions ({total_component_E_D:.2f} W) "
|
|
223
|
+
f"does not match overall system exergy destruction ({self.E_D:.2f} W)."
|
|
224
|
+
)
|
|
223
225
|
else:
|
|
224
|
-
logging.info(
|
|
226
|
+
logging.info("Exergy destruction check passed: Sum of component E_D matches overall E_D.")
|
|
225
227
|
|
|
226
228
|
@classmethod
|
|
227
229
|
def from_tespy(cls, model: str, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
|
|
@@ -247,22 +249,19 @@ class ExergyAnalysis:
|
|
|
247
249
|
"""
|
|
248
250
|
from tespy.networks import Network
|
|
249
251
|
|
|
250
|
-
from .parser.from_tespy.
|
|
252
|
+
from .parser.from_tespy.tespy_parser import to_exerpy
|
|
251
253
|
|
|
252
254
|
if isinstance(model, str):
|
|
253
255
|
model = Network.from_json(model)
|
|
254
256
|
elif isinstance(model, Network):
|
|
255
257
|
pass
|
|
256
258
|
else:
|
|
257
|
-
msg =
|
|
258
|
-
"Model parameter must be a path to a valid tespy network "
|
|
259
|
-
"export or a tespy network"
|
|
260
|
-
)
|
|
259
|
+
msg = "Model parameter must be a path to a valid tespy network " "export or a tespy network"
|
|
261
260
|
raise TypeError(msg)
|
|
262
261
|
|
|
263
|
-
data =
|
|
262
|
+
data = to_exerpy(model, Tamb, pamb)
|
|
264
263
|
data, Tamb, pamb = _process_json(data, Tamb, pamb, chemExLib, split_physical_exergy)
|
|
265
|
-
return cls(data[
|
|
264
|
+
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
266
265
|
|
|
267
266
|
@classmethod
|
|
268
267
|
def from_aspen(cls, path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
|
|
@@ -272,7 +271,7 @@ class ExergyAnalysis:
|
|
|
272
271
|
Parameters
|
|
273
272
|
----------
|
|
274
273
|
path : str
|
|
275
|
-
Path to the
|
|
274
|
+
Path to the Aspen file (.bkp format).
|
|
276
275
|
Tamb : float, optional
|
|
277
276
|
Ambient temperature for analysis, default is None.
|
|
278
277
|
pamb : float, optional
|
|
@@ -285,7 +284,7 @@ class ExergyAnalysis:
|
|
|
285
284
|
Returns
|
|
286
285
|
-------
|
|
287
286
|
ExergyAnalysis
|
|
288
|
-
An instance of the ExergyAnalysis class with parsed
|
|
287
|
+
An instance of the ExergyAnalysis class with parsed Aspen data.
|
|
289
288
|
"""
|
|
290
289
|
|
|
291
290
|
from .parser.from_aspen import aspen_parser as aspen_parser
|
|
@@ -293,21 +292,22 @@ class ExergyAnalysis:
|
|
|
293
292
|
# Check if the file is an Aspen file
|
|
294
293
|
_, file_extension = os.path.splitext(path)
|
|
295
294
|
|
|
296
|
-
if file_extension ==
|
|
297
|
-
logging.info("Running
|
|
295
|
+
if file_extension == ".bkp":
|
|
296
|
+
logging.info("Running Aspen parsing and generating JSON data.")
|
|
298
297
|
data = aspen_parser.run_aspen(path, split_physical_exergy=split_physical_exergy)
|
|
299
|
-
logging.info("
|
|
298
|
+
logging.info("Parsing completed successfully.")
|
|
300
299
|
|
|
301
300
|
else:
|
|
302
301
|
# 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
|
-
)
|
|
302
|
+
raise ValueError(f"Unsupported file format: {file_extension}. Please provide " "an Aspen (.bkp) file.")
|
|
307
303
|
|
|
308
304
|
data, Tamb, pamb = _process_json(
|
|
309
|
-
data,
|
|
310
|
-
|
|
305
|
+
data,
|
|
306
|
+
Tamb=Tamb,
|
|
307
|
+
pamb=pamb,
|
|
308
|
+
chemExLib=chemExLib,
|
|
309
|
+
split_physical_exergy=split_physical_exergy,
|
|
310
|
+
required_component_fields=["name", "type"],
|
|
311
311
|
)
|
|
312
312
|
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
313
313
|
|
|
@@ -340,21 +340,22 @@ class ExergyAnalysis:
|
|
|
340
340
|
# Check if the file is an Ebsilon file
|
|
341
341
|
_, file_extension = os.path.splitext(path)
|
|
342
342
|
|
|
343
|
-
if file_extension ==
|
|
343
|
+
if file_extension == ".ebs":
|
|
344
344
|
logging.info("Running Ebsilon simulation and generating JSON data.")
|
|
345
345
|
data = ebs_parser.run_ebsilon(path, split_physical_exergy=split_physical_exergy)
|
|
346
346
|
logging.info("Simulation completed successfully.")
|
|
347
347
|
|
|
348
348
|
else:
|
|
349
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
|
-
)
|
|
350
|
+
raise ValueError(f"Unsupported file format: {file_extension}. Please provide " "an Ebsilon (.ebs) file.")
|
|
354
351
|
|
|
355
352
|
data, Tamb, pamb = _process_json(
|
|
356
|
-
data,
|
|
357
|
-
|
|
353
|
+
data,
|
|
354
|
+
Tamb=Tamb,
|
|
355
|
+
pamb=pamb,
|
|
356
|
+
chemExLib=chemExLib,
|
|
357
|
+
split_physical_exergy=split_physical_exergy,
|
|
358
|
+
required_component_fields=["name", "type", "type_index"],
|
|
358
359
|
)
|
|
359
360
|
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
360
361
|
|
|
@@ -392,7 +393,7 @@ class ExergyAnalysis:
|
|
|
392
393
|
data, Tamb, pamb = _process_json(
|
|
393
394
|
data, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy
|
|
394
395
|
)
|
|
395
|
-
return cls(data[
|
|
396
|
+
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
396
397
|
|
|
397
398
|
def exergy_results(self, print_results=True):
|
|
398
399
|
"""
|
|
@@ -412,8 +413,10 @@ class ExergyAnalysis:
|
|
|
412
413
|
(df_component_results, df_material_connection_results, df_non_material_connection_results)
|
|
413
414
|
with the exergy analysis results.
|
|
414
415
|
"""
|
|
416
|
+
|
|
415
417
|
# Define the lambda function for safe multiplication
|
|
416
|
-
convert
|
|
418
|
+
def convert(x, factor):
|
|
419
|
+
return x * factor if x is not None else None
|
|
417
420
|
|
|
418
421
|
# COMPONENTS
|
|
419
422
|
component_results = {
|
|
@@ -424,14 +427,14 @@ class ExergyAnalysis:
|
|
|
424
427
|
"E_L [kW]": [],
|
|
425
428
|
"ε [%]": [],
|
|
426
429
|
"y [%]": [],
|
|
427
|
-
"y* [%]": []
|
|
430
|
+
"y* [%]": [],
|
|
428
431
|
}
|
|
429
432
|
|
|
430
433
|
# Populate the dictionary with exergy analysis data from each component,
|
|
431
434
|
# excluding CycleCloser components.
|
|
432
435
|
for component_name, component in self.components.items():
|
|
433
436
|
# Exclude components whose class name is "CycleCloser"
|
|
434
|
-
if component.__class__.__name__ == "CycleCloser":
|
|
437
|
+
if component.__class__.__name__ == "CycleCloser" or component.__class__.__name__ == "PowerBus":
|
|
435
438
|
continue
|
|
436
439
|
|
|
437
440
|
component_results["Component"].append(component_name)
|
|
@@ -439,7 +442,9 @@ class ExergyAnalysis:
|
|
|
439
442
|
E_F_kW = convert(component.E_F, 1e-3)
|
|
440
443
|
E_P_kW = convert(component.E_P, 1e-3)
|
|
441
444
|
E_D_kW = convert(component.E_D, 1e-3)
|
|
442
|
-
E_L_kW =
|
|
445
|
+
E_L_kW = (
|
|
446
|
+
convert(getattr(component, "E_L", None), 1e-3) if getattr(component, "E_L", None) is not None else 0
|
|
447
|
+
)
|
|
443
448
|
epsilon_percent = convert(component.epsilon, 1e2)
|
|
444
449
|
|
|
445
450
|
component_results["E_F [kW]"].append(E_F_kW)
|
|
@@ -458,7 +463,7 @@ class ExergyAnalysis:
|
|
|
458
463
|
|
|
459
464
|
# Add the overall results to the components as dummy component "TOT"
|
|
460
465
|
df_component_results.loc["TOT", "E_F [kW]"] = convert(self.E_F, 1e-3)
|
|
461
|
-
df_component_results.loc["TOT", "Component"] =
|
|
466
|
+
df_component_results.loc["TOT", "Component"] = "TOT"
|
|
462
467
|
df_component_results.loc["TOT", "E_L [kW]"] = convert(self.E_L, 1e-3)
|
|
463
468
|
df_component_results.loc["TOT", "E_P [kW]"] = convert(self.E_P, 1e-3)
|
|
464
469
|
df_component_results.loc["TOT", "E_D [kW]"] = convert(self.E_D, 1e-3)
|
|
@@ -479,42 +484,57 @@ class ExergyAnalysis:
|
|
|
479
484
|
"e^PH [kJ/kg]": [],
|
|
480
485
|
"e^T [kJ/kg]": [],
|
|
481
486
|
"e^M [kJ/kg]": [],
|
|
482
|
-
"e^CH [kJ/kg]": []
|
|
487
|
+
"e^CH [kJ/kg]": [],
|
|
483
488
|
}
|
|
484
489
|
|
|
485
490
|
# NON-MATERIAL CONNECTIONS
|
|
486
|
-
non_material_connection_results = {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
"Exergy Flow [kW]": []
|
|
491
|
-
}
|
|
491
|
+
non_material_connection_results = {"Connection": [], "Kind": [], "Energy Flow [kW]": [], "Exergy Flow [kW]": []}
|
|
492
|
+
|
|
493
|
+
# Create set of valid component names
|
|
494
|
+
valid_components = {comp.name for comp in self.components.values()}
|
|
492
495
|
|
|
493
496
|
# Populate the dictionaries with exergy analysis data for each connection
|
|
494
497
|
for conn_name, conn_data in self.connections.items():
|
|
498
|
+
|
|
499
|
+
# Filter: only include connections that have source OR target in self.components
|
|
500
|
+
is_part_of_the_system = (
|
|
501
|
+
conn_data.get("source_component") in valid_components
|
|
502
|
+
or conn_data.get("target_component") in valid_components
|
|
503
|
+
)
|
|
504
|
+
if not is_part_of_the_system:
|
|
505
|
+
continue
|
|
506
|
+
|
|
495
507
|
# Separate material and non-material connections based on fluid type
|
|
496
508
|
kind = conn_data.get("kind", None)
|
|
497
509
|
|
|
498
510
|
# Check if the connection is a non-material energy flow type
|
|
499
|
-
if kind in {
|
|
511
|
+
if kind in {"power", "heat"}:
|
|
500
512
|
# Non-material connections: only record energy flow, converted to kW using lambda
|
|
501
513
|
non_material_connection_results["Connection"].append(conn_name)
|
|
502
514
|
non_material_connection_results["Kind"].append(kind)
|
|
503
515
|
non_material_connection_results["Energy Flow [kW]"].append(convert(conn_data.get("energy_flow"), 1e-3))
|
|
504
516
|
non_material_connection_results["Exergy Flow [kW]"].append(convert(conn_data.get("E"), 1e-3))
|
|
505
|
-
elif kind ==
|
|
517
|
+
elif kind == "material":
|
|
506
518
|
# Material connections: record full data with conversions using lambda
|
|
507
519
|
material_connection_results["Connection"].append(conn_name)
|
|
508
|
-
material_connection_results["m [kg/s]"].append(conn_data.get(
|
|
509
|
-
material_connection_results["T [°C]"].append(conn_data.get(
|
|
510
|
-
material_connection_results["p [bar]"].append(convert(conn_data.get(
|
|
511
|
-
material_connection_results["h [kJ/kg]"].append(convert(conn_data.get(
|
|
512
|
-
material_connection_results["s [J/kgK]"].append(conn_data.get(
|
|
513
|
-
material_connection_results["e^PH [kJ/kg]"].append(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
material_connection_results["e^
|
|
517
|
-
|
|
520
|
+
material_connection_results["m [kg/s]"].append(conn_data.get("m", None))
|
|
521
|
+
material_connection_results["T [°C]"].append(conn_data.get("T") - 273.15) # Convert to °C
|
|
522
|
+
material_connection_results["p [bar]"].append(convert(conn_data.get("p"), 1e-5)) # Convert Pa to bar
|
|
523
|
+
material_connection_results["h [kJ/kg]"].append(convert(conn_data.get("h"), 1e-3)) # Convert to kJ/kg
|
|
524
|
+
material_connection_results["s [J/kgK]"].append(conn_data.get("s", None))
|
|
525
|
+
material_connection_results["e^PH [kJ/kg]"].append(
|
|
526
|
+
convert(conn_data.get("e_PH"), 1e-3)
|
|
527
|
+
) # Convert to kJ/kg
|
|
528
|
+
material_connection_results["e^T [kJ/kg]"].append(
|
|
529
|
+
convert(conn_data.get("e_T"), 1e-3)
|
|
530
|
+
) # Convert to kJ/kg
|
|
531
|
+
material_connection_results["e^M [kJ/kg]"].append(
|
|
532
|
+
convert(conn_data.get("e_M"), 1e-3)
|
|
533
|
+
) # Convert to kJ/kg
|
|
534
|
+
material_connection_results["e^CH [kJ/kg]"].append(
|
|
535
|
+
convert(conn_data.get("e_CH"), 1e-3)
|
|
536
|
+
) # Convert to kJ/kg
|
|
537
|
+
material_connection_results["E [kW]"].append(convert(conn_data.get("E"), 1e-3)) # Convert to kW
|
|
518
538
|
|
|
519
539
|
# Convert the material and non-material connection dictionaries into DataFrames
|
|
520
540
|
df_material_connection_results = pd.DataFrame(material_connection_results)
|
|
@@ -527,39 +547,264 @@ class ExergyAnalysis:
|
|
|
527
547
|
if print_results:
|
|
528
548
|
# Print the material connection results DataFrame in the console in a table format
|
|
529
549
|
print("\nMaterial Connection Exergy Analysis Results:")
|
|
530
|
-
print(
|
|
550
|
+
print(
|
|
551
|
+
tabulate(
|
|
552
|
+
df_material_connection_results.reset_index(drop=True),
|
|
553
|
+
headers="keys",
|
|
554
|
+
tablefmt="psql",
|
|
555
|
+
floatfmt=".3f",
|
|
556
|
+
)
|
|
557
|
+
)
|
|
531
558
|
|
|
532
559
|
# Print the non-material connection results DataFrame in the console in a table format
|
|
533
560
|
print("\nNon-Material Connection Exergy Analysis Results:")
|
|
534
|
-
print(
|
|
561
|
+
print(
|
|
562
|
+
tabulate(
|
|
563
|
+
df_non_material_connection_results.reset_index(drop=True),
|
|
564
|
+
headers="keys",
|
|
565
|
+
tablefmt="psql",
|
|
566
|
+
floatfmt=".3f",
|
|
567
|
+
)
|
|
568
|
+
)
|
|
535
569
|
|
|
536
570
|
# Print the component results DataFrame in the console in a table format
|
|
537
571
|
print("\nComponent Exergy Analysis Results:")
|
|
538
|
-
print(
|
|
572
|
+
print(
|
|
573
|
+
tabulate(df_component_results.reset_index(drop=True), headers="keys", tablefmt="psql", floatfmt=".3f")
|
|
574
|
+
)
|
|
539
575
|
|
|
540
576
|
return df_component_results, df_material_connection_results, df_non_material_connection_results
|
|
541
577
|
|
|
578
|
+
def plot_exergy_waterfall(self, title=None, figsize=(12, 10), exclude_components=None, show_plot=True):
|
|
579
|
+
"""
|
|
580
|
+
Create an exergy destruction waterfall diagram.
|
|
581
|
+
|
|
582
|
+
This method visualizes the exergy flow through the system as a waterfall chart,
|
|
583
|
+
showing how exergy is destroyed in each component from the exergetic fuel (100%)
|
|
584
|
+
down to the exergetic product and losses.
|
|
585
|
+
|
|
586
|
+
Parameters
|
|
587
|
+
----------
|
|
588
|
+
title : str, optional
|
|
589
|
+
Title for the plot. If None, no title is displayed.
|
|
590
|
+
figsize : tuple, optional
|
|
591
|
+
Figure size as (width, height) in inches. Default is (12, 10).
|
|
592
|
+
exclude_components : list, optional
|
|
593
|
+
List of component names to exclude from the diagram.
|
|
594
|
+
By default, all components with NaN E_F (Exergetic Fuel) are excluded,
|
|
595
|
+
as well as CycleCloser and PowerBus components.
|
|
596
|
+
show_plot : bool, optional
|
|
597
|
+
Whether to display the plot immediately. Default is True.
|
|
598
|
+
|
|
599
|
+
Returns
|
|
600
|
+
-------
|
|
601
|
+
fig : matplotlib.figure.Figure
|
|
602
|
+
The figure object containing the waterfall diagram.
|
|
603
|
+
ax : matplotlib.axes.Axes
|
|
604
|
+
The axes object of the waterfall diagram.
|
|
605
|
+
|
|
606
|
+
Raises
|
|
607
|
+
------
|
|
608
|
+
RuntimeError
|
|
609
|
+
If the exergy analysis has not been performed yet (analyse() not called).
|
|
610
|
+
|
|
611
|
+
Notes
|
|
612
|
+
-----
|
|
613
|
+
- The waterfall diagram displays exergy values as percentages of the total fuel exergy.
|
|
614
|
+
- Components are sorted by their exergy destruction rate (y [%]) in descending order.
|
|
615
|
+
- Each bar represents the remaining exergy after destruction in that component.
|
|
616
|
+
- Red bars indicate exergy destruction in components.
|
|
617
|
+
- Blue bar represents the initial exergetic fuel (100%).
|
|
618
|
+
- Green bar represents the final exergetic product.
|
|
619
|
+
|
|
620
|
+
Examples
|
|
621
|
+
--------
|
|
622
|
+
>>> analysis = ExergyAnalysis.from_tespy(network, Tamb=288.15, pamb=101325) # doctest: +SKIP
|
|
623
|
+
>>> analysis.analyse(E_F={'inputs': ['fuel']}, E_P={'outputs': ['power']}) # doctest: +SKIP
|
|
624
|
+
>>> fig, ax = analysis.plot_exergy_waterfall(title='Power Plant Exergy Waterfall') # doctest: +SKIP
|
|
625
|
+
>>> fig.savefig('exergy_waterfall.pdf') # doctest: +SKIP
|
|
626
|
+
|
|
627
|
+
See Also
|
|
628
|
+
--------
|
|
629
|
+
exergy_results : Display tabular exergy analysis results.
|
|
630
|
+
print_exergy_summary : Print a text summary of exergy analysis.
|
|
631
|
+
"""
|
|
632
|
+
# Check if analysis has been performed
|
|
633
|
+
if not hasattr(self, "epsilon") or self.epsilon is None:
|
|
634
|
+
raise RuntimeError("Exergy analysis has not been performed yet. Please call analyse() first.")
|
|
635
|
+
|
|
636
|
+
# Get component results without printing
|
|
637
|
+
df_component_results, _, _ = self.exergy_results(print_results=False)
|
|
638
|
+
|
|
639
|
+
# Default exclusions: empty list, but filter for valid E_F
|
|
640
|
+
if exclude_components is None:
|
|
641
|
+
exclude_components = []
|
|
642
|
+
|
|
643
|
+
# Get total values from df_component_results
|
|
644
|
+
total_row = df_component_results[df_component_results["Component"] == "TOT"].iloc[0]
|
|
645
|
+
epsilon_total = total_row["ε [%]"]
|
|
646
|
+
E_L_total = total_row["E_L [kW]"]
|
|
647
|
+
E_F_total = total_row["E_F [kW]"]
|
|
648
|
+
exergetic_loss_percent = (E_L_total / E_F_total) * 100 if E_F_total != 0 else 0
|
|
649
|
+
|
|
650
|
+
# Filter components (exclude TOT, components with NaN E_F, and specified components)
|
|
651
|
+
component_data = df_component_results[
|
|
652
|
+
(df_component_results["Component"] != "TOT")
|
|
653
|
+
& (df_component_results["E_F [kW]"].notna())
|
|
654
|
+
& (~df_component_results["Component"].isin(exclude_components))
|
|
655
|
+
& (df_component_results["y [%]"].notna())
|
|
656
|
+
].copy()
|
|
657
|
+
|
|
658
|
+
# Sort by y [%] in descending order
|
|
659
|
+
component_data = component_data.sort_values("y [%]", ascending=False)
|
|
660
|
+
|
|
661
|
+
# Create bar values: Start at 100%, decrease by each component's y [%]
|
|
662
|
+
bar_values = [100.0]
|
|
663
|
+
current_value = 100.0
|
|
664
|
+
for y in component_data["y [%]"]:
|
|
665
|
+
current_value -= y
|
|
666
|
+
bar_values.append(current_value)
|
|
667
|
+
bar_values.append(epsilon_total) # Final bar is the exergetic product
|
|
668
|
+
|
|
669
|
+
# Create labels for spaces between bars
|
|
670
|
+
space_labels = ["Exergetic fuel"] + list(component_data["Component"]) + ["Exergetic loss", "Exergetic product"]
|
|
671
|
+
|
|
672
|
+
# Create the figure
|
|
673
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
674
|
+
|
|
675
|
+
# Number of bars and spaces
|
|
676
|
+
n_bars = len(bar_values)
|
|
677
|
+
|
|
678
|
+
# Create horizontal bars at positions 0, 1, 2, ..., n_bars-1
|
|
679
|
+
bar_positions = np.arange(n_bars)
|
|
680
|
+
bar_colors = ["#1565C0"] + ["#D32F2F"] * (n_bars - 2) + ["#2E7D32"]
|
|
681
|
+
# Blue for fuel, red for destruction, green for product
|
|
682
|
+
|
|
683
|
+
for i, (pos, value, color) in enumerate(zip(bar_positions, bar_values, bar_colors, strict=False)):
|
|
684
|
+
ax.barh(pos, value, color=color, alpha=0.8, height=0.6)
|
|
685
|
+
# Add value label inside the bar on the right
|
|
686
|
+
ax.text(
|
|
687
|
+
value - 2, pos, f"{value:.2f}%", va="center", ha="right", fontsize=9, fontweight="bold", color="white"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Add space labels between bars
|
|
691
|
+
# Space positions: between bars, so at 0.5, 1.5, 2.5, ..., and above/below
|
|
692
|
+
space_positions = [-0.5] + [i + 0.5 for i in range(n_bars - 1)] + [n_bars - 0.5]
|
|
693
|
+
|
|
694
|
+
for i, (space_pos, label) in enumerate(zip(space_positions, space_labels, strict=False)):
|
|
695
|
+
if i == 0: # Exergetic fuel - above first bar
|
|
696
|
+
ax.text(2, space_pos, label, va="center", ha="left", fontsize=10, fontweight="bold", style="italic")
|
|
697
|
+
elif i == len(space_labels) - 2: # Exergetic loss
|
|
698
|
+
loss_label = f"{label} (-{exergetic_loss_percent:.2f}%)"
|
|
699
|
+
ax.text(
|
|
700
|
+
2, space_pos, loss_label, va="center", ha="left", fontsize=10, fontweight="bold", style="italic"
|
|
701
|
+
)
|
|
702
|
+
elif i == len(space_labels) - 1: # Exergetic product - below last bar
|
|
703
|
+
ax.text(2, space_pos, label, va="center", ha="left", fontsize=10, fontweight="bold", style="italic")
|
|
704
|
+
else: # Component labels
|
|
705
|
+
component_idx = i - 1
|
|
706
|
+
destruction_rate = component_data.iloc[component_idx]["y [%]"]
|
|
707
|
+
label_with_rate = f"{label} (-{destruction_rate:.2f}%)"
|
|
708
|
+
ax.text(2, space_pos, label_with_rate, va="center", ha="left", fontsize=10, fontweight="bold")
|
|
709
|
+
|
|
710
|
+
# Customize plot
|
|
711
|
+
ax.set_yticks(bar_positions)
|
|
712
|
+
ax.set_yticklabels([""] * n_bars) # Empty labels since we have custom labels
|
|
713
|
+
ax.set_xlabel("Exergy [%]", fontsize=12, fontweight="bold")
|
|
714
|
+
|
|
715
|
+
if title is not None:
|
|
716
|
+
ax.set_title(title, fontsize=14, fontweight="bold", pad=20)
|
|
717
|
+
|
|
718
|
+
ax.set_xlim(0, 100)
|
|
719
|
+
ax.set_ylim(-1, n_bars)
|
|
720
|
+
ax.grid(axis="x", alpha=0.3, linestyle="--")
|
|
721
|
+
ax.axvline(x=0, color="black", linewidth=0.8)
|
|
722
|
+
ax.invert_yaxis() # Invert so highest bar is at top
|
|
723
|
+
|
|
724
|
+
plt.tight_layout()
|
|
725
|
+
|
|
726
|
+
if show_plot:
|
|
727
|
+
plt.show()
|
|
728
|
+
|
|
729
|
+
return fig, ax
|
|
730
|
+
|
|
731
|
+
def print_exergy_summary(self):
|
|
732
|
+
"""
|
|
733
|
+
Print a text summary of the exergy analysis results.
|
|
734
|
+
|
|
735
|
+
This method provides a concise summary of the overall system exergy performance,
|
|
736
|
+
including fuel exergy, total destruction, losses, and efficiency.
|
|
737
|
+
|
|
738
|
+
Raises
|
|
739
|
+
------
|
|
740
|
+
RuntimeError
|
|
741
|
+
If the exergy analysis has not been performed yet (analyse() not called).
|
|
742
|
+
|
|
743
|
+
Notes
|
|
744
|
+
-----
|
|
745
|
+
The summary includes:
|
|
746
|
+
- Exergetic Fuel: Normalized to 100%
|
|
747
|
+
- Total Exergy Destruction: Sum of all component exergy destructions as % of fuel
|
|
748
|
+
- Exergetic Loss: Exergy losses to the environment as % of fuel
|
|
749
|
+
- Exergetic Product (ε): Overall system exergy efficiency as %
|
|
750
|
+
|
|
751
|
+
Examples
|
|
752
|
+
--------
|
|
753
|
+
>>> analysis = ExergyAnalysis.from_tespy(network, Tamb=288.15, pamb=101325) # doctest: +SKIP
|
|
754
|
+
>>> analysis.analyse(E_F={'inputs': ['fuel']}, E_P={'outputs': ['power']}) # doctest: +SKIP
|
|
755
|
+
>>> analysis.print_exergy_summary() # doctest: +SKIP
|
|
756
|
+
Exergy Analysis Summary:
|
|
757
|
+
Exergetic Fuel: 100.00%
|
|
758
|
+
Total Exergy Destruction: 35.42%
|
|
759
|
+
Exergetic Loss: 5.12%
|
|
760
|
+
Exergetic Product (ε): 59.46%
|
|
761
|
+
|
|
762
|
+
See Also
|
|
763
|
+
--------
|
|
764
|
+
exergy_results : Display detailed tabular results.
|
|
765
|
+
plot_exergy_waterfall : Create a visual waterfall diagram.
|
|
766
|
+
"""
|
|
767
|
+
# Check if analysis has been performed
|
|
768
|
+
if not hasattr(self, "epsilon") or self.epsilon is None:
|
|
769
|
+
raise RuntimeError("Exergy analysis has not been performed yet. Please call analyse() first.")
|
|
770
|
+
|
|
771
|
+
# Get component results without printing
|
|
772
|
+
df_component_results, _, _ = self.exergy_results(print_results=False)
|
|
773
|
+
|
|
774
|
+
total_row = df_component_results[df_component_results["Component"] == "TOT"].iloc[0]
|
|
775
|
+
epsilon_total = total_row["ε [%]"]
|
|
776
|
+
E_L_total = total_row["E_L [kW]"]
|
|
777
|
+
E_F_total = total_row["E_F [kW]"]
|
|
778
|
+
exergetic_loss_percent = (E_L_total / E_F_total) * 100 if E_F_total != 0 else 0
|
|
779
|
+
|
|
780
|
+
print("\nExergy Analysis Summary:")
|
|
781
|
+
print("Exergetic Fuel: 100.00%")
|
|
782
|
+
print(f"Total Exergy Destruction: {100 - epsilon_total - exergetic_loss_percent:.2f}%")
|
|
783
|
+
print(f"Exergetic Loss: {exergetic_loss_percent:.2f}%")
|
|
784
|
+
print(f"Exergetic Product (epsilon): {epsilon_total:.2f}%")
|
|
785
|
+
|
|
542
786
|
def export_to_json(self, output_path):
|
|
543
787
|
"""
|
|
544
788
|
Export the model to a JSON file.
|
|
545
|
-
|
|
789
|
+
|
|
546
790
|
Parameters
|
|
547
791
|
----------
|
|
548
792
|
output_path : str
|
|
549
793
|
Path where the JSON file will be saved.
|
|
550
|
-
|
|
794
|
+
|
|
551
795
|
Returns
|
|
552
796
|
-------
|
|
553
797
|
None
|
|
554
798
|
The model is saved to the specified path.
|
|
555
|
-
|
|
799
|
+
|
|
556
800
|
Notes
|
|
557
801
|
-----
|
|
558
802
|
This method serializes the model using the internal _serialize method
|
|
559
803
|
and writes the resulting data to a JSON file with indentation.
|
|
804
|
+
NaN values are converted to null for valid JSON output.
|
|
560
805
|
"""
|
|
561
806
|
data = self._serialize()
|
|
562
|
-
with open(output_path,
|
|
807
|
+
with open(output_path, "w") as json_file:
|
|
563
808
|
json.dump(data, json_file, indent=4)
|
|
564
809
|
logging.info(f"Model exported to JSON file: {output_path}.")
|
|
565
810
|
|
|
@@ -578,19 +823,69 @@ class ExergyAnalysis:
|
|
|
578
823
|
export = {}
|
|
579
824
|
export["components"] = self._component_data
|
|
580
825
|
export["connections"] = self._connection_data
|
|
581
|
-
export["ambient_conditions"] = {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
826
|
+
export["ambient_conditions"] = {"Tamb": self.Tamb, "Tamb_unit": "K", "pamb": self.pamb, "pamb_unit": "Pa"}
|
|
827
|
+
export["settings"] = {"split_physical_exergy": self.split_physical_exergy, "chemExLib": self.chemExLib}
|
|
828
|
+
|
|
829
|
+
# add per-component exergy results
|
|
830
|
+
for _comp_type, comps in export["components"].items():
|
|
831
|
+
for comp_name, comp_data in comps.items():
|
|
832
|
+
comp = self.components[comp_name]
|
|
833
|
+
comp_data["exergy_results"] = {
|
|
834
|
+
"E_F": _nan_to_none(getattr(comp, "E_F", None)),
|
|
835
|
+
"E_P": _nan_to_none(getattr(comp, "E_P", None)),
|
|
836
|
+
"E_D": _nan_to_none(getattr(comp, "E_D", None)),
|
|
837
|
+
"epsilon": _nan_to_none(getattr(comp, "epsilon", None)),
|
|
838
|
+
"y": _nan_to_none(getattr(comp, "y", None)),
|
|
839
|
+
"y_star": _nan_to_none(getattr(comp, "y_star", None)),
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
# add overall system exergy results
|
|
843
|
+
export["system_results"] = {
|
|
844
|
+
"E_F": _nan_to_none(getattr(self, "E_F", None)),
|
|
845
|
+
"E_P": _nan_to_none(getattr(self, "E_P", None)),
|
|
846
|
+
"E_D": _nan_to_none(getattr(self, "E_D", None)),
|
|
847
|
+
"E_L": _nan_to_none(getattr(self, "E_L", None)),
|
|
848
|
+
"epsilon": _nan_to_none(getattr(self, "epsilon", None)),
|
|
590
849
|
}
|
|
591
850
|
|
|
592
851
|
return export
|
|
593
852
|
|
|
853
|
+
def _check_unaccounted_system_conns(self):
|
|
854
|
+
"""
|
|
855
|
+
Check if system boundary connections are not included in E_F, E_P, or E_L dictionaries.
|
|
856
|
+
"""
|
|
857
|
+
# Collect all accounted streams
|
|
858
|
+
accounted_streams = set()
|
|
859
|
+
for dictionary in [self.E_F_dict, self.E_P_dict, self.E_L_dict]:
|
|
860
|
+
accounted_streams.update(dictionary.get("inputs", []))
|
|
861
|
+
accounted_streams.update(dictionary.get("outputs", []))
|
|
862
|
+
|
|
863
|
+
# Identify actual system boundary connections
|
|
864
|
+
# A connection is at the boundary if source OR target is None/missing
|
|
865
|
+
system_boundary_conns = []
|
|
866
|
+
for conn_name, conn_data in self.connections.items():
|
|
867
|
+
source = conn_data.get("source_component", None)
|
|
868
|
+
target = conn_data.get("target_component", None)
|
|
869
|
+
if conn_data.get("source_component_type", None) == 1:
|
|
870
|
+
source = None
|
|
871
|
+
|
|
872
|
+
# Connection is at system boundary if one side is not connected
|
|
873
|
+
if source is None or target is None:
|
|
874
|
+
kind = conn_data.get("kind", "")
|
|
875
|
+
exergy = conn_data.get("E", 0)
|
|
876
|
+
# Only consider material/heat/power streams with significant exergy
|
|
877
|
+
if kind in ["material", "heat", "power"] and abs(exergy) > 1e-3:
|
|
878
|
+
system_boundary_conns.append(conn_name)
|
|
879
|
+
|
|
880
|
+
# Find unaccounted boundary connections
|
|
881
|
+
unaccounted = [conn for conn in system_boundary_conns if conn not in accounted_streams]
|
|
882
|
+
|
|
883
|
+
if unaccounted:
|
|
884
|
+
conn_list = ", ".join(f"'{conn}'" for conn in sorted(unaccounted))
|
|
885
|
+
logging.warning(
|
|
886
|
+
f"The following system boundary connections are not included in E_F, E_P, or E_L: {conn_list}"
|
|
887
|
+
)
|
|
888
|
+
|
|
594
889
|
|
|
595
890
|
def _construct_components(component_data, connection_data, Tamb):
|
|
596
891
|
"""
|
|
@@ -620,9 +915,9 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
620
915
|
for component_type, component_instances in component_data.items():
|
|
621
916
|
for component_name, component_information in component_instances.items():
|
|
622
917
|
# Skip components of type 'Splitter'
|
|
623
|
-
if component_type == "Splitter" or component_information.get('type') == "Splitter":
|
|
624
|
-
|
|
625
|
-
|
|
918
|
+
"""if component_type == "Splitter" or component_information.get('type') == "Splitter":
|
|
919
|
+
logging.info(f"Skipping 'Splitter' component during the exergy analysis: {component_name}")
|
|
920
|
+
continue # Skip this component"""
|
|
626
921
|
|
|
627
922
|
# Fetch the corresponding class from the registry using the component type
|
|
628
923
|
component_class = component_registry.items.get(component_type)
|
|
@@ -639,15 +934,15 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
639
934
|
component.outl = {}
|
|
640
935
|
|
|
641
936
|
# Assign streams to the components based on connection data
|
|
642
|
-
for
|
|
937
|
+
for _conn_id, conn_info in connection_data.items():
|
|
643
938
|
# Assign inlet streams
|
|
644
|
-
if conn_info[
|
|
645
|
-
target_connector_idx = conn_info[
|
|
939
|
+
if conn_info["target_component"] == component_name:
|
|
940
|
+
target_connector_idx = conn_info["target_connector"] # Use 0-based indexing
|
|
646
941
|
component.inl[target_connector_idx] = conn_info # Assign inlet stream
|
|
647
942
|
|
|
648
943
|
# Assign outlet streams
|
|
649
|
-
if conn_info[
|
|
650
|
-
source_connector_idx = conn_info[
|
|
944
|
+
if conn_info["source_component"] == component_name:
|
|
945
|
+
source_connector_idx = conn_info["source_connector"] # Use 0-based indexing
|
|
651
946
|
component.outl[source_connector_idx] = conn_info # Assign outlet stream
|
|
652
947
|
|
|
653
948
|
# --- NEW: Automatically mark Valve components as dissipative ---
|
|
@@ -663,7 +958,7 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
663
958
|
else:
|
|
664
959
|
component.is_dissipative = False
|
|
665
960
|
except Exception as e:
|
|
666
|
-
logging.warning(f"Could not evaluate
|
|
961
|
+
logging.warning(f"Could not evaluate if Valve '{component_name}' is dissipative or not: {e}")
|
|
667
962
|
component.is_dissipative = False
|
|
668
963
|
else:
|
|
669
964
|
component.is_dissipative = False
|
|
@@ -674,6 +969,13 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
674
969
|
return components # Return the dictionary of created components
|
|
675
970
|
|
|
676
971
|
|
|
972
|
+
def _nan_to_none(value):
|
|
973
|
+
"""Convert NaN/Inf floats to None for valid JSON serialization."""
|
|
974
|
+
if isinstance(value, float) and not np.isfinite(value):
|
|
975
|
+
return None
|
|
976
|
+
return value
|
|
977
|
+
|
|
978
|
+
|
|
677
979
|
def _load_json(json_path):
|
|
678
980
|
"""
|
|
679
981
|
Load and validate a JSON file.
|
|
@@ -698,15 +1000,17 @@ def _load_json(json_path):
|
|
|
698
1000
|
if not os.path.exists(json_path):
|
|
699
1001
|
raise FileNotFoundError(f"File not found: {json_path}")
|
|
700
1002
|
|
|
701
|
-
if not json_path.endswith(
|
|
1003
|
+
if not json_path.endswith(".json"):
|
|
702
1004
|
raise ValueError("File must have .json extension")
|
|
703
1005
|
|
|
704
1006
|
# Load and validate JSON
|
|
705
|
-
with open(json_path
|
|
1007
|
+
with open(json_path) as file:
|
|
706
1008
|
return json.load(file)
|
|
707
1009
|
|
|
708
1010
|
|
|
709
|
-
def _process_json(
|
|
1011
|
+
def _process_json(
|
|
1012
|
+
data, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True, required_component_fields=None
|
|
1013
|
+
):
|
|
710
1014
|
"""Process JSON data to prepare it for exergy analysis.
|
|
711
1015
|
This function validates the data structure, ensures all required fields are present,
|
|
712
1016
|
and enriches the data with chemical exergy and total exergy flow calculations.
|
|
@@ -734,29 +1038,31 @@ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exe
|
|
|
734
1038
|
If required sections or fields are missing, or if data structure is invalid
|
|
735
1039
|
"""
|
|
736
1040
|
# Validate required sections
|
|
737
|
-
|
|
1041
|
+
if required_component_fields is None:
|
|
1042
|
+
required_component_fields = ["name"]
|
|
1043
|
+
required_sections = ["components", "connections", "ambient_conditions"]
|
|
738
1044
|
missing_sections = [s for s in required_sections if s not in data]
|
|
739
1045
|
if missing_sections:
|
|
740
1046
|
raise ValueError(f"Missing required sections: {missing_sections}")
|
|
741
1047
|
|
|
742
1048
|
# Check for mass_composition in material streams if chemical exergy is requested
|
|
743
1049
|
if chemExLib:
|
|
744
|
-
for conn_name, conn_data in data[
|
|
745
|
-
if conn_data.get(
|
|
1050
|
+
for conn_name, conn_data in data["connections"].items():
|
|
1051
|
+
if conn_data.get("kind") == "material" and "mass_composition" not in conn_data:
|
|
746
1052
|
raise ValueError(f"Material stream '{conn_name}' missing mass_composition")
|
|
747
1053
|
|
|
748
1054
|
# Extract or use provided ambient conditions
|
|
749
|
-
Tamb = Tamb or data[
|
|
750
|
-
pamb = pamb or data[
|
|
1055
|
+
Tamb = Tamb or data["ambient_conditions"].get("Tamb")
|
|
1056
|
+
pamb = pamb or data["ambient_conditions"].get("pamb")
|
|
751
1057
|
|
|
752
1058
|
if Tamb is None or pamb is None:
|
|
753
1059
|
raise ValueError("Ambient conditions (Tamb, pamb) must be provided either in JSON or as parameters")
|
|
754
1060
|
|
|
755
1061
|
# Validate component data structure
|
|
756
|
-
if not isinstance(data[
|
|
1062
|
+
if not isinstance(data["components"], dict):
|
|
757
1063
|
raise ValueError("Components section must be a dictionary")
|
|
758
1064
|
|
|
759
|
-
for comp_type, components in data[
|
|
1065
|
+
for comp_type, components in data["components"].items():
|
|
760
1066
|
if not isinstance(components, dict):
|
|
761
1067
|
raise ValueError(f"Component type '{comp_type}' must contain dictionary of components")
|
|
762
1068
|
|
|
@@ -766,8 +1072,8 @@ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exe
|
|
|
766
1072
|
raise ValueError(f"Component '{comp_name}' missing required fields: {missing_fields}")
|
|
767
1073
|
|
|
768
1074
|
# Validate connection data structure
|
|
769
|
-
for conn_name, conn_data in data[
|
|
770
|
-
required_conn_fields = [
|
|
1075
|
+
for conn_name, conn_data in data["connections"].items():
|
|
1076
|
+
required_conn_fields = ["kind", "source_component", "target_component"]
|
|
771
1077
|
missing_fields = [f for f in required_conn_fields if f not in conn_data]
|
|
772
1078
|
if missing_fields:
|
|
773
1079
|
raise ValueError(f"Connection '{conn_name}' missing required fields: {missing_fields}")
|
|
@@ -787,7 +1093,7 @@ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exe
|
|
|
787
1093
|
|
|
788
1094
|
|
|
789
1095
|
class ExergoeconomicAnalysis:
|
|
790
|
-
""""
|
|
1096
|
+
""" "
|
|
791
1097
|
This class performs exergoeconomic analysis on a previously completed exergy analysis.
|
|
792
1098
|
It takes the results from an ExergyAnalysis instance and builds upon them
|
|
793
1099
|
to conduct a complete exergoeconomic analysis. It constructs and solves a system
|
|
@@ -872,12 +1178,12 @@ class ExergoeconomicAnalysis:
|
|
|
872
1178
|
|
|
873
1179
|
This method assigns unique indices to each cost variable in the matrix system
|
|
874
1180
|
and populates a dictionary mapping these indices to variable names.
|
|
875
|
-
|
|
1181
|
+
|
|
876
1182
|
For material streams, separate indices are assigned for thermal, mechanical,
|
|
877
1183
|
and chemical exergy components when chemical exergy is enabled (otherwise only
|
|
878
1184
|
thermal and mechanical). For non-material streams (heat, power), a single index
|
|
879
1185
|
is assigned for the total exergy cost.
|
|
880
|
-
|
|
1186
|
+
|
|
881
1187
|
Notes
|
|
882
1188
|
-----
|
|
883
1189
|
The assigned indices are used for constructing the cost balance equations
|
|
@@ -889,8 +1195,9 @@ class ExergoeconomicAnalysis:
|
|
|
889
1195
|
# Process each connection (stream) which is part of the system (has a valid source or target)
|
|
890
1196
|
for name, conn in self.connections.items():
|
|
891
1197
|
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
|
-
|
|
1198
|
+
is_part_of_the_system = (conn.get("source_component") in valid_components) or (
|
|
1199
|
+
conn.get("target_component") in valid_components
|
|
1200
|
+
)
|
|
894
1201
|
if not is_part_of_the_system:
|
|
895
1202
|
continue
|
|
896
1203
|
else:
|
|
@@ -898,21 +1205,14 @@ class ExergoeconomicAnalysis:
|
|
|
898
1205
|
# For material streams, assign indices based on the flag.
|
|
899
1206
|
if kind == "material":
|
|
900
1207
|
if self.exergy_analysis.chemical_exergy_enabled:
|
|
901
|
-
conn["CostVar_index"] = {
|
|
902
|
-
|
|
903
|
-
"M": col_number + 1,
|
|
904
|
-
"CH": col_number + 2
|
|
905
|
-
}
|
|
906
|
-
self.variables[str(col_number)] = f"C_{name}_T"
|
|
1208
|
+
conn["CostVar_index"] = {"T": col_number, "M": col_number + 1, "CH": col_number + 2}
|
|
1209
|
+
self.variables[str(col_number)] = f"C_{name}_T"
|
|
907
1210
|
self.variables[str(col_number + 1)] = f"C_{name}_M"
|
|
908
1211
|
self.variables[str(col_number + 2)] = f"C_{name}_CH"
|
|
909
1212
|
col_number += 3
|
|
910
1213
|
else:
|
|
911
|
-
conn["CostVar_index"] = {
|
|
912
|
-
|
|
913
|
-
"M": col_number + 1
|
|
914
|
-
}
|
|
915
|
-
self.variables[str(col_number)] = f"C_{name}_T"
|
|
1214
|
+
conn["CostVar_index"] = {"T": col_number, "M": col_number + 1}
|
|
1215
|
+
self.variables[str(col_number)] = f"C_{name}_T"
|
|
916
1216
|
self.variables[str(col_number + 1)] = f"C_{name}_M"
|
|
917
1217
|
col_number += 2
|
|
918
1218
|
# Check if this connection's target is a dissipative component.
|
|
@@ -922,7 +1222,7 @@ class ExergoeconomicAnalysis:
|
|
|
922
1222
|
if comp is not None and getattr(comp, "is_dissipative", False):
|
|
923
1223
|
# Add an extra index for the dissipative cost difference.
|
|
924
1224
|
conn["CostVar_index"]["dissipative"] = col_number
|
|
925
|
-
self.variables[str(col_number)] = "
|
|
1225
|
+
self.variables[str(col_number)] = f"dissipative_{comp.name}"
|
|
926
1226
|
col_number += 1
|
|
927
1227
|
# For non-material streams (e.g., heat, power), assign one index.
|
|
928
1228
|
elif kind in ("heat", "power"):
|
|
@@ -958,14 +1258,16 @@ class ExergoeconomicAnalysis:
|
|
|
958
1258
|
"""
|
|
959
1259
|
# --- Component Costs ---
|
|
960
1260
|
for comp_name, comp in self.components.items():
|
|
961
|
-
if isinstance(comp, CycleCloser):
|
|
1261
|
+
if isinstance(comp, CycleCloser | PowerBus):
|
|
962
1262
|
continue
|
|
963
1263
|
else:
|
|
964
1264
|
cost_key = f"{comp_name}_Z"
|
|
965
1265
|
if cost_key in Exe_Eco_Costs:
|
|
966
1266
|
comp.Z_costs = Exe_Eco_Costs[cost_key] / 3600 # Convert currency/h to currency/s
|
|
967
1267
|
else:
|
|
968
|
-
raise ValueError(
|
|
1268
|
+
raise ValueError(
|
|
1269
|
+
f"Cost for component '{comp_name}' is mandatory but not provided in Exe_Eco_Costs."
|
|
1270
|
+
)
|
|
969
1271
|
|
|
970
1272
|
# --- Connection Costs ---
|
|
971
1273
|
accepted_kinds = {"material", "heat", "power"}
|
|
@@ -983,7 +1285,9 @@ class ExergoeconomicAnalysis:
|
|
|
983
1285
|
|
|
984
1286
|
# For input connections (except for power connections) a cost is mandatory.
|
|
985
1287
|
if is_input and kind != "power" and cost_key not in Exe_Eco_Costs:
|
|
986
|
-
raise ValueError(
|
|
1288
|
+
raise ValueError(
|
|
1289
|
+
f"Cost for input connection '{conn_name}' is mandatory but not provided in Exe_Eco_Costs."
|
|
1290
|
+
)
|
|
987
1291
|
|
|
988
1292
|
# Assign cost if provided.
|
|
989
1293
|
if cost_key in Exe_Eco_Costs:
|
|
@@ -1012,23 +1316,22 @@ class ExergoeconomicAnalysis:
|
|
|
1012
1316
|
# Assign only the total cost for heat and power streams.
|
|
1013
1317
|
conn["C_TOT"] = c_TOT * conn["E"]
|
|
1014
1318
|
|
|
1015
|
-
|
|
1016
1319
|
def construct_matrix(self, Tamb):
|
|
1017
1320
|
"""
|
|
1018
1321
|
Construct the exergoeconomic cost matrix and vector.
|
|
1019
|
-
|
|
1322
|
+
|
|
1020
1323
|
Parameters
|
|
1021
1324
|
----------
|
|
1022
1325
|
Tamb : float
|
|
1023
1326
|
Ambient temperature in Kelvin.
|
|
1024
|
-
|
|
1327
|
+
|
|
1025
1328
|
Returns
|
|
1026
1329
|
-------
|
|
1027
1330
|
tuple
|
|
1028
1331
|
A tuple containing:
|
|
1029
1332
|
- A: numpy.ndarray - The coefficient matrix for the linear equation system
|
|
1030
1333
|
- b: numpy.ndarray - The right-hand side vector for the linear equation system
|
|
1031
|
-
|
|
1334
|
+
|
|
1032
1335
|
Notes
|
|
1033
1336
|
-----
|
|
1034
1337
|
This method constructs a system of linear equations that includes:
|
|
@@ -1038,9 +1341,8 @@ class ExergoeconomicAnalysis:
|
|
|
1038
1341
|
4. Custom auxiliary equations from each component
|
|
1039
1342
|
5. Special equations for dissipative components
|
|
1040
1343
|
"""
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
b = np.zeros(num_vars)
|
|
1344
|
+
self._A = np.zeros((self.num_variables, self.num_variables))
|
|
1345
|
+
self._b = np.zeros(self.num_variables)
|
|
1044
1346
|
counter = 0
|
|
1045
1347
|
|
|
1046
1348
|
# Filter out CycleCloser instances, keeping the component objects.
|
|
@@ -1050,26 +1352,26 @@ class ExergoeconomicAnalysis:
|
|
|
1050
1352
|
|
|
1051
1353
|
# 1. Cost balance equations for productive components.
|
|
1052
1354
|
for comp in valid_components:
|
|
1053
|
-
if
|
|
1054
|
-
|
|
1355
|
+
if (
|
|
1356
|
+
not getattr(comp, "is_dissipative", False)
|
|
1357
|
+
and not isinstance(comp, Splitter)
|
|
1358
|
+
and not isinstance(comp, PowerBus)
|
|
1359
|
+
):
|
|
1360
|
+
# Assign the row index for the cost balance equation to this component.
|
|
1055
1361
|
comp.exergy_cost_line = counter
|
|
1056
1362
|
for conn in self.connections.values():
|
|
1057
1363
|
# Check if the connection is linked to a valid component.
|
|
1058
1364
|
# If the connection's target is the component, it is an inlet (add +1).
|
|
1059
1365
|
if conn.get("target_component") == comp.name:
|
|
1060
|
-
for
|
|
1061
|
-
|
|
1366
|
+
for _key, col in conn["CostVar_index"].items():
|
|
1367
|
+
self._A[counter, col] = 1 # Incoming costs
|
|
1062
1368
|
# If the connection's source is the component, it is an outlet (subtract -1).
|
|
1063
1369
|
elif conn.get("source_component") == comp.name:
|
|
1064
|
-
for
|
|
1065
|
-
|
|
1066
|
-
self.equations[counter] =
|
|
1370
|
+
for _key, col in conn["CostVar_index"].items():
|
|
1371
|
+
self._A[counter, col] = -1 # Outgoing costs
|
|
1372
|
+
self.equations[counter] = {"kind": "cost_balance", "object": [comp.name], "property": "Z_costs"}
|
|
1067
1373
|
|
|
1068
|
-
|
|
1069
|
-
if getattr(comp, "is_dissipative", False):
|
|
1070
|
-
continue
|
|
1071
|
-
else:
|
|
1072
|
-
b[counter] = -getattr(comp, "Z_costs", 1)
|
|
1374
|
+
self._b[counter] = -getattr(comp, "Z_costs", 1)
|
|
1073
1375
|
counter += 1
|
|
1074
1376
|
|
|
1075
1377
|
# 2. Inlet stream equations.
|
|
@@ -1081,25 +1383,23 @@ class ExergoeconomicAnalysis:
|
|
|
1081
1383
|
for name, conn in self.connections.items():
|
|
1082
1384
|
# A connection is treated as an inlet if its source_component is missing or not part of the system
|
|
1083
1385
|
# 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
|
-
|
|
1386
|
+
if (conn.get("source_component") is None or conn.get("source_component") not in self.components) and (
|
|
1387
|
+
conn.get("target_component") in valid_component_names
|
|
1388
|
+
):
|
|
1086
1389
|
kind = conn.get("kind", "material")
|
|
1087
1390
|
if kind == "material":
|
|
1088
|
-
if self.chemical_exergy_enabled
|
|
1089
|
-
exergy_terms = ["T", "M", "CH"]
|
|
1090
|
-
else:
|
|
1091
|
-
exergy_terms = ["T", "M"]
|
|
1391
|
+
exergy_terms = ["T", "M", "CH"] if self.chemical_exergy_enabled else ["T", "M"]
|
|
1092
1392
|
for label in exergy_terms:
|
|
1093
1393
|
idx = conn["CostVar_index"][label]
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
self.equations[counter] = f"
|
|
1394
|
+
self._A[counter, idx] = 1 # Fix the cost variable.
|
|
1395
|
+
self._b[counter] = conn.get(f"C_{label}", conn.get("C_TOT", 0))
|
|
1396
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": f"c_{label}"}
|
|
1097
1397
|
counter += 1
|
|
1098
1398
|
elif kind == "heat":
|
|
1099
1399
|
idx = conn["CostVar_index"]["exergy"]
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
self.equations[counter] =
|
|
1400
|
+
self._A[counter, idx] = 1
|
|
1401
|
+
self._b[counter] = conn.get("C_TOT", 0)
|
|
1402
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": "c_TOT"}
|
|
1103
1403
|
counter += 1
|
|
1104
1404
|
elif kind == "power":
|
|
1105
1405
|
if not has_power_outlet:
|
|
@@ -1107,19 +1407,36 @@ class ExergoeconomicAnalysis:
|
|
|
1107
1407
|
if not conn.get("C_TOT"):
|
|
1108
1408
|
continue
|
|
1109
1409
|
idx = conn["CostVar_index"]["exergy"]
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
self.equations[counter] =
|
|
1410
|
+
self._A[counter, idx] = 1
|
|
1411
|
+
self._b[counter] = conn.get("C_TOT", 0)
|
|
1412
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": "c_TOT"}
|
|
1113
1413
|
counter += 1
|
|
1114
1414
|
else:
|
|
1115
|
-
|
|
1415
|
+
# When there are power outlets, we still need at least one boundary condition
|
|
1416
|
+
# for power inlets to fix the absolute cost value. The aux_power_eq only
|
|
1417
|
+
# equalizes specific costs but doesn't set the actual value.
|
|
1418
|
+
if conn.get("C_TOT"):
|
|
1419
|
+
idx = conn["CostVar_index"]["exergy"]
|
|
1420
|
+
self._A[counter, idx] = 1
|
|
1421
|
+
self._b[counter] = conn.get("C_TOT", 0)
|
|
1422
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": "c_TOT"}
|
|
1423
|
+
counter += 1
|
|
1116
1424
|
|
|
1117
1425
|
# 3. Auxiliary equations for the equality of the specific costs
|
|
1118
1426
|
# of all power flows at the input or output of the system.
|
|
1119
|
-
power_conns = [
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1427
|
+
power_conns = [
|
|
1428
|
+
conn
|
|
1429
|
+
for conn in self.connections.values()
|
|
1430
|
+
if conn.get("kind") == "power"
|
|
1431
|
+
and (
|
|
1432
|
+
conn.get("source_component") not in valid_component_names
|
|
1433
|
+
or conn.get("target_component") not in valid_component_names
|
|
1434
|
+
)
|
|
1435
|
+
and not (
|
|
1436
|
+
conn.get("source_component") not in valid_component_names
|
|
1437
|
+
and conn.get("target_component") not in valid_component_names
|
|
1438
|
+
)
|
|
1439
|
+
]
|
|
1123
1440
|
|
|
1124
1441
|
# Only add auxiliary equations if there is more than one power connection.
|
|
1125
1442
|
if len(power_conns) > 1:
|
|
@@ -1128,10 +1445,14 @@ class ExergoeconomicAnalysis:
|
|
|
1128
1445
|
ref_idx = ref["CostVar_index"]["exergy"]
|
|
1129
1446
|
for conn in power_conns[1:]:
|
|
1130
1447
|
cur_idx = conn["CostVar_index"]["exergy"]
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
self.equations[counter] =
|
|
1448
|
+
self._A[counter, ref_idx] = 1 / ref["E"] if ref["E"] != 0 else 1
|
|
1449
|
+
self._A[counter, cur_idx] = -1 / conn["E"] if conn["E"] != 0 else -1
|
|
1450
|
+
self._b[counter] = 0
|
|
1451
|
+
self.equations[counter] = {
|
|
1452
|
+
"kind": "aux_power_eq",
|
|
1453
|
+
"objects": [ref["name"], conn["name"]],
|
|
1454
|
+
"property": "c_TOT",
|
|
1455
|
+
}
|
|
1135
1456
|
counter += 1
|
|
1136
1457
|
|
|
1137
1458
|
# 4. Auxiliary equations.
|
|
@@ -1144,7 +1465,9 @@ class ExergoeconomicAnalysis:
|
|
|
1144
1465
|
if hasattr(comp, "aux_eqs") and callable(comp.aux_eqs):
|
|
1145
1466
|
# The aux_eqs function should accept the current matrix, vector, counter, and Tamb,
|
|
1146
1467
|
# and return the updated (A, b, counter).
|
|
1147
|
-
|
|
1468
|
+
self._A, self._b, counter, self.equations = comp.aux_eqs(
|
|
1469
|
+
self._A, self._b, counter, Tamb, self.equations, self.chemical_exergy_enabled
|
|
1470
|
+
)
|
|
1148
1471
|
else:
|
|
1149
1472
|
# If no auxiliary equations are provided.
|
|
1150
1473
|
logging.warning(f"No auxiliary equations provided for component '{comp.name}'.")
|
|
@@ -1154,34 +1477,38 @@ class ExergoeconomicAnalysis:
|
|
|
1154
1477
|
# This will build an equation that integrates the dissipative cost difference (C_diff)
|
|
1155
1478
|
# into the overall cost balance (i.e. it charges the component’s Z_costs accordingly).
|
|
1156
1479
|
for comp in self.components.values():
|
|
1157
|
-
if getattr(comp, "is_dissipative", False):
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1480
|
+
if getattr(comp, "is_dissipative", False) and hasattr(comp, "dis_eqs") and callable(comp.dis_eqs):
|
|
1481
|
+
# Let the component provide its own modifications for the cost matrix.
|
|
1482
|
+
self._A, self._b, counter, self.equations = comp.dis_eqs(
|
|
1483
|
+
self._A,
|
|
1484
|
+
self._b,
|
|
1485
|
+
counter,
|
|
1486
|
+
Tamb,
|
|
1487
|
+
self.equations,
|
|
1488
|
+
self.chemical_exergy_enabled,
|
|
1489
|
+
list(self.components.values()),
|
|
1490
|
+
)
|
|
1164
1491
|
|
|
1165
1492
|
def solve_exergoeconomic_analysis(self, Tamb):
|
|
1166
1493
|
"""
|
|
1167
1494
|
Solve the exergoeconomic cost balance equations and assign the results to connections and components.
|
|
1168
|
-
|
|
1495
|
+
|
|
1169
1496
|
Parameters
|
|
1170
1497
|
----------
|
|
1171
1498
|
Tamb : float
|
|
1172
1499
|
Ambient temperature in Kelvin.
|
|
1173
|
-
|
|
1500
|
+
|
|
1174
1501
|
Returns
|
|
1175
1502
|
-------
|
|
1176
1503
|
tuple
|
|
1177
|
-
(exergy_cost_matrix, exergy_cost_vector) - The coefficient matrix and right-hand side vector used
|
|
1504
|
+
(exergy_cost_matrix, exergy_cost_vector) - The coefficient matrix and right-hand side vector used
|
|
1178
1505
|
in the linear equation system.
|
|
1179
|
-
|
|
1506
|
+
|
|
1180
1507
|
Raises
|
|
1181
1508
|
------
|
|
1182
1509
|
ValueError
|
|
1183
1510
|
If the exergoeconomic system is singular or if the cost balance is not satisfied.
|
|
1184
|
-
|
|
1511
|
+
|
|
1185
1512
|
Notes
|
|
1186
1513
|
-----
|
|
1187
1514
|
This method performs the following steps:
|
|
@@ -1193,61 +1520,72 @@ class ExergoeconomicAnalysis:
|
|
|
1193
1520
|
6. Computes system-level cost variables
|
|
1194
1521
|
"""
|
|
1195
1522
|
# Step 1: Construct the cost matrix
|
|
1196
|
-
|
|
1523
|
+
self.construct_matrix(Tamb)
|
|
1197
1524
|
|
|
1198
1525
|
# Step 2: Solve the system of equations
|
|
1199
1526
|
try:
|
|
1200
|
-
C_solution = np.linalg.solve(
|
|
1527
|
+
C_solution = np.linalg.solve(self._A, self._b)
|
|
1528
|
+
if np.isnan(C_solution).any():
|
|
1529
|
+
raise ValueError(
|
|
1530
|
+
"The solution of the cost matrix contains NaN values, indicating an issue with the cost balance equations or specifications."
|
|
1531
|
+
)
|
|
1201
1532
|
except np.linalg.LinAlgError:
|
|
1202
|
-
raise ValueError(
|
|
1203
|
-
|
|
1533
|
+
raise ValueError(
|
|
1534
|
+
f"Exergoeconomic system is singular and cannot be solved. "
|
|
1535
|
+
f"Provided equations: {len(self.equations)}, variables in system: {len(self.variables)}"
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
# Step 3: Distribute the cost differences of dissipative components to the serving components
|
|
1539
|
+
self.distribute_all_Z_diff(C_solution)
|
|
1204
1540
|
|
|
1205
|
-
# Step
|
|
1541
|
+
# Step 4: Assign solutions to connections
|
|
1206
1542
|
for conn_name, conn in self.connections.items():
|
|
1207
|
-
is_part_of_the_system =
|
|
1543
|
+
is_part_of_the_system = (
|
|
1544
|
+
conn.get("source_component") in self.components or conn.get("target_component") in self.components
|
|
1545
|
+
)
|
|
1208
1546
|
if not is_part_of_the_system:
|
|
1209
1547
|
continue
|
|
1210
1548
|
else:
|
|
1211
1549
|
kind = conn.get("kind")
|
|
1212
1550
|
if kind == "material":
|
|
1213
1551
|
# Retrieve mass flow and specific exergy values
|
|
1214
|
-
m_val = conn.get("m", 1)
|
|
1215
|
-
e_T = conn.get("e_T", 0)
|
|
1216
|
-
e_M = conn.get("e_M", 0)
|
|
1217
|
-
E_T = m_val * e_T
|
|
1218
|
-
E_M = m_val * e_M
|
|
1552
|
+
m_val = conn.get("m", 1) # mass flow [kg/s]
|
|
1553
|
+
e_T = conn.get("e_T", 0) # thermal specific exergy [kJ/kg]
|
|
1554
|
+
e_M = conn.get("e_M", 0) # mechanical specific exergy [kJ/kg]
|
|
1555
|
+
E_T = m_val * e_T # thermal exergy flow [kW]
|
|
1556
|
+
E_M = m_val * e_M # mechanical exergy flow [kW]
|
|
1219
1557
|
|
|
1220
1558
|
conn["C_T"] = C_solution[conn["CostVar_index"]["T"]]
|
|
1221
|
-
conn["c_T"] = conn["C_T"] / E_T if E_T != 0 else
|
|
1559
|
+
conn["c_T"] = conn["C_T"] / E_T if E_T != 0 else 0.0
|
|
1222
1560
|
|
|
1223
1561
|
conn["C_M"] = C_solution[conn["CostVar_index"]["M"]]
|
|
1224
|
-
conn["c_M"] = conn["C_M"] / E_M if E_M != 0 else
|
|
1562
|
+
conn["c_M"] = conn["C_M"] / E_M if E_M != 0 else 0.0
|
|
1225
1563
|
|
|
1226
1564
|
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
|
|
1565
|
+
conn["c_PH"] = conn["C_PH"] / (E_T + E_M) if (E_T + E_M) != 0 else 0.0
|
|
1228
1566
|
|
|
1229
1567
|
if self.chemical_exergy_enabled:
|
|
1230
|
-
e_CH = conn.get("e_CH", 0)
|
|
1231
|
-
E_CH = m_val * e_CH
|
|
1568
|
+
e_CH = conn.get("e_CH", 0) # chemical specific exergy [kJ/kg]
|
|
1569
|
+
E_CH = m_val * e_CH # chemical exergy flow [kW]
|
|
1232
1570
|
conn["C_CH"] = C_solution[conn["CostVar_index"]["CH"]]
|
|
1233
|
-
conn["c_CH"] = conn["C_CH"] / E_CH if E_CH != 0 else
|
|
1571
|
+
conn["c_CH"] = conn["C_CH"] / E_CH if E_CH != 0 else 0.0
|
|
1234
1572
|
conn["C_TOT"] = conn["C_T"] + conn["C_M"] + conn["C_CH"]
|
|
1235
1573
|
total_E = E_T + E_M + E_CH
|
|
1236
|
-
conn["c_TOT"] = conn["C_TOT"] / total_E if total_E != 0 else
|
|
1574
|
+
conn["c_TOT"] = conn["C_TOT"] / total_E if total_E != 0 else 0.0
|
|
1237
1575
|
else:
|
|
1238
1576
|
conn["C_TOT"] = conn["C_T"] + conn["C_M"]
|
|
1239
1577
|
total_E = E_T + E_M
|
|
1240
|
-
conn["c_TOT"] = conn["C_TOT"] / total_E if total_E != 0 else
|
|
1578
|
+
conn["c_TOT"] = conn["C_TOT"] / total_E if total_E != 0 else 0.0
|
|
1241
1579
|
elif kind in {"heat", "power"}:
|
|
1242
1580
|
conn["C_TOT"] = C_solution[conn["CostVar_index"]["exergy"]]
|
|
1243
1581
|
conn["c_TOT"] = conn["C_TOT"] / conn.get("E", 1)
|
|
1244
1582
|
|
|
1245
|
-
# Step
|
|
1583
|
+
# Step 5: Assign C_P, C_F, C_D, and f values to components
|
|
1246
1584
|
for comp in self.exergy_analysis.components.values():
|
|
1247
1585
|
if hasattr(comp, "exergoeconomic_balance") and callable(comp.exergoeconomic_balance):
|
|
1248
|
-
comp.exergoeconomic_balance(self.exergy_analysis.Tamb)
|
|
1586
|
+
comp.exergoeconomic_balance(self.exergy_analysis.Tamb, self.chemical_exergy_enabled)
|
|
1249
1587
|
|
|
1250
|
-
# Step
|
|
1588
|
+
# Step 6: Distribute the cost of loss streams to the product streams.
|
|
1251
1589
|
# For each loss stream (provided in E_L_dict), its C_TOT is distributed among the product streams (in E_P_dict)
|
|
1252
1590
|
# in proportion to their exergy (E). After the distribution the loss stream's C_TOT is set to zero.
|
|
1253
1591
|
loss_streams = self.E_L_dict.get("inputs", [])
|
|
@@ -1282,7 +1620,7 @@ class ExergoeconomicAnalysis:
|
|
|
1282
1620
|
# The cost of the loss streams are not set to zero to show
|
|
1283
1621
|
# them in the table, but they are attributed to the product streams.
|
|
1284
1622
|
|
|
1285
|
-
# Step
|
|
1623
|
+
# Step 7: Compute system-level cost variables using the E_F and E_P dictionaries.
|
|
1286
1624
|
# Compute total fuel cost (C_F_total) from fuel streams.
|
|
1287
1625
|
C_F_total = 0.0
|
|
1288
1626
|
for conn_name in self.E_F_dict.get("inputs", []):
|
|
@@ -1316,25 +1654,99 @@ class ExergoeconomicAnalysis:
|
|
|
1316
1654
|
Z_total *= 3600
|
|
1317
1655
|
|
|
1318
1656
|
# 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
|
-
}
|
|
1657
|
+
self.system_costs = {"C_F": float(C_F_total), "C_P": float(C_P_total), "Z": float(Z_total)}
|
|
1324
1658
|
|
|
1325
1659
|
# Check cost balance and raise error if violated
|
|
1326
1660
|
if abs(self.system_costs["C_P"] - self.system_costs["C_F"] - self.system_costs["Z"]) > 1e-4:
|
|
1327
1661
|
raise ValueError(
|
|
1328
|
-
f"Exergoeconomic cost balance not satisfied:
|
|
1329
|
-
f"
|
|
1662
|
+
f"Exergoeconomic cost balance of the entire system is not satisfied: \n"
|
|
1663
|
+
f"C_P = {self.system_costs['C_P']:.3f} and is not equal to \n"
|
|
1664
|
+
f"C_F ({self.system_costs['C_F']:.3f}) + ∑Z ({self.system_costs['Z']:.3f}) = {self.system_costs['C_F'] + self.system_costs['Z']:.3f}. \n"
|
|
1665
|
+
f"The problem may be caused by incorrect specifications of E_F, E_P, and E_L."
|
|
1330
1666
|
)
|
|
1331
1667
|
|
|
1332
|
-
|
|
1668
|
+
def distribute_all_Z_diff(self, C_solution):
|
|
1669
|
+
"""
|
|
1670
|
+
Distribute every dissipative cost-difference (the C_diff variables) among
|
|
1671
|
+
all components according to their `serving_weight`.
|
|
1672
|
+
|
|
1673
|
+
Parameters
|
|
1674
|
+
----------
|
|
1675
|
+
C_solution : array_like
|
|
1676
|
+
Solution vector of cost variables from the solved linear system.
|
|
1677
|
+
"""
|
|
1678
|
+
# find all indices of variables named "dissipative_*"
|
|
1679
|
+
diss_indices = [int(idx) for idx, name in self.variables.items() if name.startswith("dissipative_")]
|
|
1680
|
+
total_C_diff = sum(C_solution[i] for i in diss_indices)
|
|
1681
|
+
# assign to each component that got a serving_weight
|
|
1682
|
+
for comp in self.exergy_analysis.components.values():
|
|
1683
|
+
if hasattr(comp, "serving_weight"):
|
|
1684
|
+
comp.Z_diss = comp.serving_weight * total_C_diff
|
|
1685
|
+
|
|
1686
|
+
def check_cost_balance(self, tol=1e-6):
|
|
1687
|
+
"""
|
|
1688
|
+
Check the exergoeconomic cost balance for each component.
|
|
1689
|
+
|
|
1690
|
+
For each component, verify that
|
|
1691
|
+
sum of inlet C_TOT minus sum of outlet C_TOT plus component Z_costs
|
|
1692
|
+
equals zero within the given tolerance.
|
|
1693
|
+
|
|
1694
|
+
Parameters
|
|
1695
|
+
----------
|
|
1696
|
+
tol : float, optional
|
|
1697
|
+
Numerical tolerance for balance check (default is 1e-6).
|
|
1698
|
+
|
|
1699
|
+
Returns
|
|
1700
|
+
-------
|
|
1701
|
+
dict
|
|
1702
|
+
Mapping from component name to tuple (balance, is_balanced),
|
|
1703
|
+
where balance is the residual and is_balanced is True if
|
|
1704
|
+
abs(balance) <= tol.
|
|
1705
|
+
"""
|
|
1706
|
+
from .components.helpers.cycle_closer import CycleCloser
|
|
1707
|
+
|
|
1708
|
+
balances = {}
|
|
1709
|
+
for name, comp in self.exergy_analysis.components.items():
|
|
1710
|
+
if isinstance(comp, CycleCloser):
|
|
1711
|
+
continue
|
|
1712
|
+
inlet_sum = 0.0
|
|
1713
|
+
outlet_sum = 0.0
|
|
1714
|
+
for conn in self.connections.values():
|
|
1715
|
+
# Use sum of cost components instead of C_TOT, because C_TOT may have been
|
|
1716
|
+
# modified by loss attribution (Step 6) which is not part of the matrix equation.
|
|
1717
|
+
kind = conn.get("kind", "material")
|
|
1718
|
+
if kind == "material":
|
|
1719
|
+
cost = (conn.get("C_T", 0) or 0) + (conn.get("C_M", 0) or 0)
|
|
1720
|
+
if self.chemical_exergy_enabled:
|
|
1721
|
+
cost += conn.get("C_CH", 0) or 0
|
|
1722
|
+
else:
|
|
1723
|
+
# For heat/power streams, use C_TOT directly (no loss attribution applies)
|
|
1724
|
+
cost = conn.get("C_TOT", 0) or 0
|
|
1725
|
+
if conn.get("target_component") == name:
|
|
1726
|
+
inlet_sum += cost
|
|
1727
|
+
if conn.get("source_component") == name:
|
|
1728
|
+
outlet_sum += cost
|
|
1729
|
+
comp.C_in = inlet_sum
|
|
1730
|
+
comp.C_out = outlet_sum
|
|
1731
|
+
z_cost = getattr(comp, "Z_costs", 0)
|
|
1732
|
+
z_diss = getattr(comp, "Z_diss", 0)
|
|
1733
|
+
balance = inlet_sum - outlet_sum + z_cost + z_diss
|
|
1734
|
+
balances[name] = (balance, abs(balance) <= tol)
|
|
1735
|
+
|
|
1736
|
+
all_ok = all(flag for _, flag in balances.values())
|
|
1737
|
+
if all_ok:
|
|
1738
|
+
logging.info("All component cost balances are fulfilled.")
|
|
1739
|
+
else:
|
|
1740
|
+
for name, (bal, ok) in balances.items():
|
|
1741
|
+
if not ok:
|
|
1742
|
+
logging.warning(f"Balance for component {name} not fulfilled: residual = {bal:.6f}")
|
|
1743
|
+
|
|
1744
|
+
return balances
|
|
1333
1745
|
|
|
1334
1746
|
def run(self, Exe_Eco_Costs, Tamb):
|
|
1335
1747
|
"""
|
|
1336
1748
|
Execute the full exergoeconomic analysis.
|
|
1337
|
-
|
|
1749
|
+
|
|
1338
1750
|
Parameters
|
|
1339
1751
|
----------
|
|
1340
1752
|
Exe_Eco_Costs : dict
|
|
@@ -1343,7 +1755,7 @@ class ExergoeconomicAnalysis:
|
|
|
1343
1755
|
Format for connections: "<connection_name>_c": cost_value [currency/GJ]
|
|
1344
1756
|
Tamb : float
|
|
1345
1757
|
Ambient temperature in Kelvin.
|
|
1346
|
-
|
|
1758
|
+
|
|
1347
1759
|
Notes
|
|
1348
1760
|
-----
|
|
1349
1761
|
This method performs the complete exergoeconomic analysis by:
|
|
@@ -1354,8 +1766,108 @@ class ExergoeconomicAnalysis:
|
|
|
1354
1766
|
self.initialize_cost_variables()
|
|
1355
1767
|
self.assign_user_costs(Exe_Eco_Costs)
|
|
1356
1768
|
self.solve_exergoeconomic_analysis(Tamb)
|
|
1357
|
-
logging.info(
|
|
1769
|
+
logging.info("Exergoeconomic analysis completed successfully.")
|
|
1770
|
+
self.check_cost_balance()
|
|
1771
|
+
|
|
1772
|
+
def print_equations(self):
|
|
1773
|
+
"""
|
|
1774
|
+
Get mapping of equation indices to equation descriptions.
|
|
1775
|
+
|
|
1776
|
+
Returns
|
|
1777
|
+
-------
|
|
1778
|
+
dict
|
|
1779
|
+
Keys are equation indices (int), values are equation descriptions (str).
|
|
1780
|
+
"""
|
|
1781
|
+
return {i: self.equations[i] for i in sorted(self.equations)}
|
|
1782
|
+
|
|
1783
|
+
def print_variables(self):
|
|
1784
|
+
"""
|
|
1785
|
+
Get mapping of variable indices to variable names.
|
|
1786
|
+
|
|
1787
|
+
Returns
|
|
1788
|
+
-------
|
|
1789
|
+
dict
|
|
1790
|
+
Keys are variable indices (int), values are variable names (str).
|
|
1791
|
+
"""
|
|
1792
|
+
return {int(k): v for k, v in self.variables.items()}
|
|
1793
|
+
|
|
1794
|
+
def detect_linear_dependencies(self, tol_strict: float = 1e-12, tol_near: float = 1e-8):
|
|
1795
|
+
"""
|
|
1796
|
+
Scan A for zero-rows, zero-cols, exactly colinear equation pairs
|
|
1797
|
+
(error ≤ tol_strict), and near-colinear pairs (≤ tol_near but > tol_strict).
|
|
1798
|
+
"""
|
|
1799
|
+
A = self._A
|
|
1800
|
+
|
|
1801
|
+
# 1) empty rows/cols
|
|
1802
|
+
zero_rows = np.where((np.abs(A) < tol_strict).all(axis=1))[0].tolist()
|
|
1803
|
+
zero_cols = np.where((np.abs(A) < tol_strict).all(axis=0))[0].tolist()
|
|
1804
|
+
|
|
1805
|
+
# 2) norms once
|
|
1806
|
+
norms = np.linalg.norm(A, axis=1)
|
|
1807
|
+
|
|
1808
|
+
strict = []
|
|
1809
|
+
near = []
|
|
1810
|
+
n = A.shape[0]
|
|
1811
|
+
for i in range(n):
|
|
1812
|
+
for j in range(i + 1, n):
|
|
1813
|
+
ni, nj = norms[i], norms[j]
|
|
1814
|
+
if ni > tol_strict and nj > tol_strict:
|
|
1815
|
+
dot = float(np.dot(A[i], A[j]))
|
|
1816
|
+
diff = abs(dot - ni * nj)
|
|
1817
|
+
if diff <= tol_strict:
|
|
1818
|
+
strict.append((i, j))
|
|
1819
|
+
elif diff <= tol_near:
|
|
1820
|
+
near.append((i, j))
|
|
1821
|
+
|
|
1822
|
+
# drop any strict pairs from the near list
|
|
1823
|
+
near_only = [pair for pair in near if pair not in strict]
|
|
1824
|
+
|
|
1825
|
+
return {
|
|
1826
|
+
"zero_rows": zero_rows,
|
|
1827
|
+
"zero_columns": zero_cols,
|
|
1828
|
+
"colinear_equations_strict": strict,
|
|
1829
|
+
"colinear_equations_near_only": near_only,
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
def print_dependency_report(self, tol_strict: float = 1e-12, tol_near: float = 1e-8):
|
|
1833
|
+
"""
|
|
1834
|
+
Nicely print which equations or variables are under- or over-determined,
|
|
1835
|
+
distinguishing exact vs. near colinearities.
|
|
1836
|
+
"""
|
|
1837
|
+
deps = self.detect_linear_dependencies(tol_strict, tol_near)
|
|
1358
1838
|
|
|
1839
|
+
# empty equations
|
|
1840
|
+
if deps["zero_rows"]:
|
|
1841
|
+
print("⚠ Equations with no variables:")
|
|
1842
|
+
for eq in deps["zero_rows"]:
|
|
1843
|
+
print(f" • Eq[{eq}]: {self.equations.get(eq)}")
|
|
1844
|
+
else:
|
|
1845
|
+
print("✓ No empty equations.")
|
|
1846
|
+
|
|
1847
|
+
# unused variables
|
|
1848
|
+
if deps["zero_columns"]:
|
|
1849
|
+
print("\n⚠ Variables never used in any equation:")
|
|
1850
|
+
for var in deps["zero_columns"]:
|
|
1851
|
+
name = self.variables.get(str(var))
|
|
1852
|
+
print(f" • Var[{var}]: {name}")
|
|
1853
|
+
else:
|
|
1854
|
+
print("✓ All variables appear in at least one equation.")
|
|
1855
|
+
|
|
1856
|
+
# exactly colinear
|
|
1857
|
+
if deps["colinear_equations_strict"]:
|
|
1858
|
+
print("\n⚠ Exactly colinear (redundant) equation pairs:")
|
|
1859
|
+
for i, j in deps["colinear_equations_strict"]:
|
|
1860
|
+
print(f" • Eq[{i}] {self.equations[i]!r} ≈ Eq[{j}] {self.equations[j]!r}")
|
|
1861
|
+
else:
|
|
1862
|
+
print("✓ No exactly colinear equation pairs detected.")
|
|
1863
|
+
|
|
1864
|
+
# near-colinear
|
|
1865
|
+
if deps["colinear_equations_near_only"]:
|
|
1866
|
+
print("\n⚠ Nearly colinear equation pairs (|dot−‖i‖‖j‖| ≤ tol_near):")
|
|
1867
|
+
for i, j in deps["colinear_equations_near_only"]:
|
|
1868
|
+
print(f" • Eq[{i}] {self.equations[i]!r} ≈? Eq[{j}] {self.equations[j]!r}")
|
|
1869
|
+
else:
|
|
1870
|
+
print("✓ No near-colinear equation pairs detected.")
|
|
1359
1871
|
|
|
1360
1872
|
def exergoeconomic_results(self, print_results=True):
|
|
1361
1873
|
"""
|
|
@@ -1388,7 +1900,7 @@ class ExergoeconomicAnalysis:
|
|
|
1388
1900
|
f_list = []
|
|
1389
1901
|
|
|
1390
1902
|
# Iterate over the component DataFrame rows. The "Component" column contains the key.
|
|
1391
|
-
for
|
|
1903
|
+
for _idx, row in df_comp.iterrows():
|
|
1392
1904
|
comp_name = row["Component"]
|
|
1393
1905
|
if comp_name != "TOT":
|
|
1394
1906
|
comp = self.components.get(comp_name, None)
|
|
@@ -1421,26 +1933,33 @@ class ExergoeconomicAnalysis:
|
|
|
1421
1933
|
df_comp[f"C_D [{self.currency}/h]"] = C_D_list
|
|
1422
1934
|
df_comp[f"Z [{self.currency}/h]"] = Z_cost_list
|
|
1423
1935
|
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[
|
|
1425
|
-
df_comp[
|
|
1936
|
+
df_comp["f [%]"] = f_list
|
|
1937
|
+
df_comp["r [%]"] = r_list
|
|
1426
1938
|
|
|
1427
1939
|
# Update the TOT row with system-level values using .loc.
|
|
1428
1940
|
df_comp.loc["TOT", f"C_F [{self.currency}/h]"] = self.system_costs.get("C_F", np.nan)
|
|
1429
1941
|
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]"]
|
|
1942
|
+
df_comp.loc["TOT", f"Z [{self.currency}/h]"] = self.system_costs.get("Z", np.nan)
|
|
1431
1943
|
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]"]
|
|
1944
|
+
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] + df_comp.loc["TOT", f"Z [{self.currency}/h]"]
|
|
1434
1945
|
)
|
|
1435
1946
|
|
|
1436
1947
|
df_comp[f"c_F [{self.currency}/GJ]"] = df_comp[f"C_F [{self.currency}/h]"] / df_comp["E_F [kW]"] * 1e6 / 3600
|
|
1437
1948
|
df_comp[f"c_P [{self.currency}/GJ]"] = df_comp[f"C_P [{self.currency}/h]"] / df_comp["E_P [kW]"] * 1e6 / 3600
|
|
1438
1949
|
|
|
1439
|
-
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] =
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
df_comp.loc["TOT", f"
|
|
1443
|
-
|
|
1950
|
+
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] = (
|
|
1951
|
+
df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"] * df_comp.loc["TOT", "E_D [kW]"] / 1e6 * 3600
|
|
1952
|
+
)
|
|
1953
|
+
df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] = (
|
|
1954
|
+
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] + df_comp.loc["TOT", f"Z [{self.currency}/h]"]
|
|
1955
|
+
)
|
|
1956
|
+
df_comp.loc["TOT", "f [%]"] = (
|
|
1957
|
+
df_comp.loc["TOT", f"Z [{self.currency}/h]"] / df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] * 100
|
|
1958
|
+
)
|
|
1959
|
+
df_comp.loc["TOT", "r [%]"] = (
|
|
1960
|
+
(df_comp.loc["TOT", f"c_P [{self.currency}/GJ]"] - df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"])
|
|
1961
|
+
/ df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"]
|
|
1962
|
+
) * 100
|
|
1444
1963
|
|
|
1445
1964
|
# -------------------------
|
|
1446
1965
|
# Add cost columns to material connections.
|
|
@@ -1450,15 +1969,31 @@ class ExergoeconomicAnalysis:
|
|
|
1450
1969
|
C_M_list = []
|
|
1451
1970
|
C_CH_list = []
|
|
1452
1971
|
C_TOT_list = []
|
|
1453
|
-
# Lowercase cost columns (in
|
|
1972
|
+
# Lowercase cost columns (in {currency}/GJ_ex)
|
|
1454
1973
|
c_T_list = []
|
|
1455
1974
|
c_M_list = []
|
|
1456
1975
|
c_CH_list = []
|
|
1457
1976
|
c_TOT_list = []
|
|
1458
1977
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1978
|
+
# Create set of valid component names
|
|
1979
|
+
valid_components = {comp.name for comp in self.components.values()}
|
|
1980
|
+
|
|
1981
|
+
for _idx, row in df_mat.iterrows():
|
|
1982
|
+
conn_name = row["Connection"]
|
|
1461
1983
|
conn_data = self.connections.get(conn_name, {})
|
|
1984
|
+
|
|
1985
|
+
# Verify connection is part of the system
|
|
1986
|
+
is_part_of_the_system = (
|
|
1987
|
+
conn_data.get("source_component") in valid_components
|
|
1988
|
+
or conn_data.get("target_component") in valid_components
|
|
1989
|
+
)
|
|
1990
|
+
if not is_part_of_the_system:
|
|
1991
|
+
# Skip this connection
|
|
1992
|
+
C_T_list.append(np.nan)
|
|
1993
|
+
C_M_list.append(np.nan)
|
|
1994
|
+
# ... append nan for all other lists
|
|
1995
|
+
continue
|
|
1996
|
+
|
|
1462
1997
|
kind = conn_data.get("kind", None)
|
|
1463
1998
|
if kind == "material":
|
|
1464
1999
|
C_T = conn_data.get("C_T", None)
|
|
@@ -1503,17 +2038,17 @@ class ExergoeconomicAnalysis:
|
|
|
1503
2038
|
df_mat[f"C^M [{self.currency}/h]"] = C_M_list
|
|
1504
2039
|
df_mat[f"C^CH [{self.currency}/h]"] = C_CH_list
|
|
1505
2040
|
df_mat[f"C^TOT [{self.currency}/h]"] = C_TOT_list
|
|
1506
|
-
df_mat[f"c^T [
|
|
1507
|
-
df_mat[f"c^M [
|
|
1508
|
-
df_mat[f"c^CH [
|
|
1509
|
-
df_mat[f"c^TOT [
|
|
2041
|
+
df_mat[f"c^T [{self.currency}/GJ_ex]"] = c_T_list
|
|
2042
|
+
df_mat[f"c^M [{self.currency}/GJ_ex]"] = c_M_list
|
|
2043
|
+
df_mat[f"c^CH [{self.currency}/GJ_ex]"] = c_CH_list
|
|
2044
|
+
df_mat[f"c^TOT [{self.currency}/GJ_ex]"] = c_TOT_list
|
|
1510
2045
|
|
|
1511
2046
|
# -------------------------
|
|
1512
2047
|
# Add cost columns to non-material connections.
|
|
1513
2048
|
# -------------------------
|
|
1514
2049
|
C_TOT_non_mat = []
|
|
1515
2050
|
c_TOT_non_mat = []
|
|
1516
|
-
for
|
|
2051
|
+
for _idx, row in df_non_mat.iterrows():
|
|
1517
2052
|
conn_name = row["Connection"]
|
|
1518
2053
|
conn_data = self.connections.get(conn_name, {})
|
|
1519
2054
|
C_TOT = conn_data.get("C_TOT", None)
|
|
@@ -1521,48 +2056,52 @@ class ExergoeconomicAnalysis:
|
|
|
1521
2056
|
c_TOT = conn_data.get("c_TOT", None)
|
|
1522
2057
|
c_TOT_non_mat.append(c_TOT * 1e9 if c_TOT is not None else None)
|
|
1523
2058
|
df_non_mat[f"C^TOT [{self.currency}/h]"] = C_TOT_non_mat
|
|
1524
|
-
df_non_mat[f"c^TOT [
|
|
2059
|
+
df_non_mat[f"c^TOT [{self.currency}/GJ_ex]"] = c_TOT_non_mat
|
|
1525
2060
|
|
|
1526
2061
|
# -------------------------
|
|
1527
2062
|
# Split the material connections into two tables according to your specifications.
|
|
1528
2063
|
# -------------------------
|
|
1529
2064
|
# df_mat1: Columns from mass flow until e^CH.
|
|
1530
|
-
df_mat1 = df_mat[
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
2065
|
+
df_mat1 = df_mat[
|
|
2066
|
+
[
|
|
2067
|
+
"Connection",
|
|
2068
|
+
"m [kg/s]",
|
|
2069
|
+
"T [°C]",
|
|
2070
|
+
"p [bar]",
|
|
2071
|
+
"h [kJ/kg]",
|
|
2072
|
+
"s [J/kgK]",
|
|
2073
|
+
"E [kW]",
|
|
2074
|
+
"e^PH [kJ/kg]",
|
|
2075
|
+
"e^T [kJ/kg]",
|
|
2076
|
+
"e^M [kJ/kg]",
|
|
2077
|
+
"e^CH [kJ/kg]",
|
|
2078
|
+
]
|
|
2079
|
+
].copy()
|
|
1543
2080
|
|
|
1544
2081
|
# df_mat2: Columns from E onward, plus the uppercase and lowercase cost columns.
|
|
1545
|
-
df_mat2 = df_mat[
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
2082
|
+
df_mat2 = df_mat[
|
|
2083
|
+
[
|
|
2084
|
+
"Connection",
|
|
2085
|
+
"E [kW]",
|
|
2086
|
+
"e^PH [kJ/kg]",
|
|
2087
|
+
"e^T [kJ/kg]",
|
|
2088
|
+
"e^M [kJ/kg]",
|
|
2089
|
+
"e^CH [kJ/kg]",
|
|
2090
|
+
f"C^T [{self.currency}/h]",
|
|
2091
|
+
f"C^M [{self.currency}/h]",
|
|
2092
|
+
f"C^CH [{self.currency}/h]",
|
|
2093
|
+
f"C^TOT [{self.currency}/h]",
|
|
2094
|
+
f"c^T [{self.currency}/GJ_ex]",
|
|
2095
|
+
f"c^M [{self.currency}/GJ_ex]",
|
|
2096
|
+
f"c^CH [{self.currency}/GJ_ex]",
|
|
2097
|
+
f"c^TOT [{self.currency}/GJ_ex]",
|
|
2098
|
+
]
|
|
2099
|
+
].copy()
|
|
1561
2100
|
|
|
1562
2101
|
# Remove any columns that contain only NaN values from df_mat1, df_mat2, and df_non_mat.
|
|
1563
|
-
df_mat1.dropna(axis=1, how=
|
|
1564
|
-
df_mat2.dropna(axis=1, how=
|
|
1565
|
-
df_non_mat.dropna(axis=1, how=
|
|
2102
|
+
df_mat1.dropna(axis=1, how="all", inplace=True)
|
|
2103
|
+
df_mat2.dropna(axis=1, how="all", inplace=True)
|
|
2104
|
+
df_non_mat.dropna(axis=1, how="all", inplace=True)
|
|
1566
2105
|
|
|
1567
2106
|
# -------------------------
|
|
1568
2107
|
# Print the four tables if requested.
|
|
@@ -1608,7 +2147,7 @@ class EconomicAnalysis:
|
|
|
1608
2147
|
def __init__(self, pars):
|
|
1609
2148
|
"""
|
|
1610
2149
|
Initialize the EconomicAnalysis with plant parameters provided in a dictionary.
|
|
1611
|
-
|
|
2150
|
+
|
|
1612
2151
|
Parameters
|
|
1613
2152
|
----------
|
|
1614
2153
|
pars : dict
|
|
@@ -1618,15 +2157,15 @@ class EconomicAnalysis:
|
|
|
1618
2157
|
- n: Lifetime of the plant (years)
|
|
1619
2158
|
- r_n: Nominal escalation rate (yearly based)
|
|
1620
2159
|
"""
|
|
1621
|
-
self.tau = pars[
|
|
1622
|
-
self.i_eff = pars[
|
|
1623
|
-
self.n = pars[
|
|
1624
|
-
self.r_n = pars[
|
|
2160
|
+
self.tau = pars["tau"]
|
|
2161
|
+
self.i_eff = pars["i_eff"]
|
|
2162
|
+
self.n = pars["n"]
|
|
2163
|
+
self.r_n = pars["r_n"]
|
|
1625
2164
|
|
|
1626
2165
|
def compute_crf(self):
|
|
1627
2166
|
"""
|
|
1628
2167
|
Compute the Capital Recovery Factor (CRF) using the effective rate of return.
|
|
1629
|
-
|
|
2168
|
+
|
|
1630
2169
|
Returns
|
|
1631
2170
|
-------
|
|
1632
2171
|
float
|
|
@@ -1636,12 +2175,12 @@ class EconomicAnalysis:
|
|
|
1636
2175
|
-----
|
|
1637
2176
|
CRF = i_eff * (1 + i_eff)**n / ((1 + i_eff)**n - 1)
|
|
1638
2177
|
"""
|
|
1639
|
-
return self.i_eff * (1 + self.i_eff)**self.n / ((1 + self.i_eff)**self.n - 1)
|
|
2178
|
+
return self.i_eff * (1 + self.i_eff) ** self.n / ((1 + self.i_eff) ** self.n - 1)
|
|
1640
2179
|
|
|
1641
2180
|
def compute_celf(self):
|
|
1642
2181
|
"""
|
|
1643
2182
|
Compute the Cost Escalation Levelization Factor (CELF) for repeating expenditures.
|
|
1644
|
-
|
|
2183
|
+
|
|
1645
2184
|
Returns
|
|
1646
2185
|
-------
|
|
1647
2186
|
float
|
|
@@ -1658,12 +2197,12 @@ class EconomicAnalysis:
|
|
|
1658
2197
|
def compute_levelized_investment_cost(self, total_PEC):
|
|
1659
2198
|
"""
|
|
1660
2199
|
Compute the levelized investment cost (annualized investment cost).
|
|
1661
|
-
|
|
2200
|
+
|
|
1662
2201
|
Parameters
|
|
1663
2202
|
----------
|
|
1664
2203
|
total_PEC : float
|
|
1665
2204
|
Total purchasing equipment cost (PEC) across all components.
|
|
1666
|
-
|
|
2205
|
+
|
|
1667
2206
|
Returns
|
|
1668
2207
|
-------
|
|
1669
2208
|
float
|
|
@@ -1674,14 +2213,14 @@ class EconomicAnalysis:
|
|
|
1674
2213
|
def compute_component_costs(self, PEC_list, OMC_relative):
|
|
1675
2214
|
"""
|
|
1676
2215
|
Compute the cost rates for each component.
|
|
1677
|
-
|
|
2216
|
+
|
|
1678
2217
|
Parameters
|
|
1679
2218
|
----------
|
|
1680
2219
|
PEC_list : list of float
|
|
1681
2220
|
The purchasing equipment cost (PEC) of each component (in currency).
|
|
1682
2221
|
OMC_relative : list of float
|
|
1683
2222
|
For each component, the first-year OM cost as a fraction of its PEC.
|
|
1684
|
-
|
|
2223
|
+
|
|
1685
2224
|
Returns
|
|
1686
2225
|
-------
|
|
1687
2226
|
tuple
|
|
@@ -1693,10 +2232,13 @@ class EconomicAnalysis:
|
|
|
1693
2232
|
total_PEC = sum(PEC_list)
|
|
1694
2233
|
# Levelize total investment cost and allocate proportionally.
|
|
1695
2234
|
levelized_investment_cost = self.compute_levelized_investment_cost(total_PEC)
|
|
1696
|
-
|
|
2235
|
+
if total_PEC == 0:
|
|
2236
|
+
Z_CC = [0 for _ in PEC_list]
|
|
2237
|
+
else:
|
|
2238
|
+
Z_CC = [(levelized_investment_cost * pec / total_PEC) / self.tau for pec in PEC_list]
|
|
1697
2239
|
|
|
1698
2240
|
# 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)]
|
|
2241
|
+
first_year_OMC = [frac * pec for frac, pec in zip(OMC_relative, PEC_list, strict=False)]
|
|
1700
2242
|
total_first_year_OMC = sum(first_year_OMC)
|
|
1701
2243
|
|
|
1702
2244
|
# Levelize the total operating and maintenance cost.
|
|
@@ -1704,8 +2246,11 @@ class EconomicAnalysis:
|
|
|
1704
2246
|
levelized_om_cost = total_first_year_OMC * celf_value
|
|
1705
2247
|
|
|
1706
2248
|
# Allocate the levelized OM cost to each component in proportion to its PEC.
|
|
1707
|
-
|
|
2249
|
+
if total_PEC == 0:
|
|
2250
|
+
Z_OM = [0 for _ in PEC_list]
|
|
2251
|
+
else:
|
|
2252
|
+
Z_OM = [(levelized_om_cost * pec / total_PEC) / self.tau for pec in PEC_list]
|
|
1708
2253
|
|
|
1709
2254
|
# 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
|
|
2255
|
+
Z_total = [zcc + zom for zcc, zom in zip(Z_CC, Z_OM, strict=False)]
|
|
2256
|
+
return Z_CC, Z_OM, Z_total
|