exerpy 0.0.2__py3-none-any.whl → 0.0.3__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 +597 -297
- 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 +186 -119
- exerpy/components/heat_exchanger/condenser.py +96 -60
- 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 +181 -63
- exerpy/components/turbomachinery/pump.py +182 -63
- exerpy/components/turbomachinery/turbine.py +182 -74
- exerpy/functions.py +388 -263
- 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 +32 -1
- exerpy/parser/from_tespy/tespy_parser.py +151 -0
- {exerpy-0.0.2.dist-info → exerpy-0.0.3.dist-info}/METADATA +43 -2
- exerpy-0.0.3.dist-info/RECORD +48 -0
- exerpy-0.0.2.dist-info/RECORD +0 -44
- {exerpy-0.0.2.dist-info → exerpy-0.0.3.dist-info}/WHEEL +0 -0
- {exerpy-0.0.2.dist-info → exerpy-0.0.3.dist-info}/licenses/LICENSE +0 -0
exerpy/analyses.py
CHANGED
|
@@ -8,8 +8,9 @@ from tabulate import tabulate
|
|
|
8
8
|
|
|
9
9
|
from .components.component import component_registry
|
|
10
10
|
from .components.helpers.cycle_closer import CycleCloser
|
|
11
|
-
from .
|
|
12
|
-
from .
|
|
11
|
+
from .components.helpers.power_bus import PowerBus
|
|
12
|
+
from .components.nodes.splitter import Splitter
|
|
13
|
+
from .functions import add_chemical_exergy, add_total_exergy_flow
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class ExergyAnalysis:
|
|
@@ -106,7 +107,7 @@ class ExergyAnalysis:
|
|
|
106
107
|
self.components = _construct_components(component_data, connection_data, Tamb)
|
|
107
108
|
self.connections = connection_data
|
|
108
109
|
|
|
109
|
-
def analyse(self, E_F, E_P, E_L=
|
|
110
|
+
def analyse(self, E_F, E_P, E_L=None) -> None:
|
|
110
111
|
"""
|
|
111
112
|
Run the exergy analysis for the entire system and calculate overall exergy efficiency.
|
|
112
113
|
|
|
@@ -120,6 +121,8 @@ class ExergyAnalysis:
|
|
|
120
121
|
Dictionary containing input and output connections for loss exergy (default is {}).
|
|
121
122
|
"""
|
|
122
123
|
# Initialize class attributes for the exergy value of the total system
|
|
124
|
+
if E_L is None:
|
|
125
|
+
E_L = {}
|
|
123
126
|
self.E_F = 0.0
|
|
124
127
|
self.E_P = 0.0
|
|
125
128
|
self.E_L = 0.0
|
|
@@ -131,53 +134,38 @@ class ExergyAnalysis:
|
|
|
131
134
|
for connections in ex_flow.values():
|
|
132
135
|
for connection in connections:
|
|
133
136
|
if connection not in self.connections:
|
|
134
|
-
msg =
|
|
135
|
-
f"The connection {connection} is not part of the "
|
|
136
|
-
"plant's connections."
|
|
137
|
-
)
|
|
137
|
+
msg = f"The connection {connection} is not part of the " "plant's connections."
|
|
138
138
|
raise ValueError(msg)
|
|
139
139
|
|
|
140
140
|
# Calculate total fuel exergy (E_F) by summing up all specified input connections
|
|
141
141
|
if "inputs" in E_F:
|
|
142
142
|
self.E_F += sum(
|
|
143
|
-
self.connections[conn][
|
|
144
|
-
for conn in E_F["inputs"]
|
|
145
|
-
if self.connections[conn]['E'] is not None
|
|
143
|
+
self.connections[conn]["E"] for conn in E_F["inputs"] if self.connections[conn]["E"] is not None
|
|
146
144
|
)
|
|
147
145
|
|
|
148
146
|
if "outputs" in E_F:
|
|
149
147
|
self.E_F -= sum(
|
|
150
|
-
self.connections[conn][
|
|
151
|
-
for conn in E_F["outputs"]
|
|
152
|
-
if self.connections[conn]['E'] is not None
|
|
148
|
+
self.connections[conn]["E"] for conn in E_F["outputs"] if self.connections[conn]["E"] is not None
|
|
153
149
|
)
|
|
154
150
|
|
|
155
151
|
# Calculate total product exergy (E_P) by summing up all specified input and output connections
|
|
156
152
|
if "inputs" in E_P:
|
|
157
153
|
self.E_P += sum(
|
|
158
|
-
self.connections[conn][
|
|
159
|
-
for conn in E_P["inputs"]
|
|
160
|
-
if self.connections[conn]['E'] is not None
|
|
154
|
+
self.connections[conn]["E"] for conn in E_P["inputs"] if self.connections[conn]["E"] is not None
|
|
161
155
|
)
|
|
162
156
|
if "outputs" in E_P:
|
|
163
157
|
self.E_P -= sum(
|
|
164
|
-
self.connections[conn][
|
|
165
|
-
for conn in E_P["outputs"]
|
|
166
|
-
if self.connections[conn]['E'] is not None
|
|
158
|
+
self.connections[conn]["E"] for conn in E_P["outputs"] if self.connections[conn]["E"] is not None
|
|
167
159
|
)
|
|
168
160
|
|
|
169
161
|
# Calculate total loss exergy (E_L) by summing up all specified input and output connections
|
|
170
162
|
if "inputs" in E_L:
|
|
171
163
|
self.E_L += sum(
|
|
172
|
-
self.connections[conn][
|
|
173
|
-
for conn in E_L["inputs"]
|
|
174
|
-
if self.connections[conn]['E'] is not None
|
|
164
|
+
self.connections[conn]["E"] for conn in E_L["inputs"] if self.connections[conn]["E"] is not None
|
|
175
165
|
)
|
|
176
166
|
if "outputs" in E_L:
|
|
177
167
|
self.E_L -= sum(
|
|
178
|
-
self.connections[conn][
|
|
179
|
-
for conn in E_L["outputs"]
|
|
180
|
-
if self.connections[conn]['E'] is not None
|
|
168
|
+
self.connections[conn]["E"] for conn in E_L["outputs"] if self.connections[conn]["E"] is not None
|
|
181
169
|
)
|
|
182
170
|
|
|
183
171
|
# Calculate overall exergy efficiency epsilon = E_P / E_F
|
|
@@ -187,19 +175,19 @@ class ExergyAnalysis:
|
|
|
187
175
|
# The rest is counted as total exergy destruction with all components of the system
|
|
188
176
|
self.E_D = self.E_F - self.E_P - self.E_L
|
|
189
177
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
178
|
+
# Check for unaccounted connections in the system
|
|
179
|
+
self._check_unaccounted_system_conns()
|
|
180
|
+
|
|
181
|
+
eff_str = f"{self.epsilon:.2%}" if self.epsilon is not None else "N/A"
|
|
194
182
|
logging.info(
|
|
195
183
|
f"Overall exergy analysis completed: E_F = {self.E_F:.2f} kW, "
|
|
196
184
|
f"E_P = {self.E_P:.2f} kW, E_L = {self.E_L:.2f} kW, "
|
|
197
185
|
f"Efficiency = {eff_str}"
|
|
198
|
-
|
|
186
|
+
)
|
|
199
187
|
|
|
200
188
|
# Perform exergy balance for each individual component in the system
|
|
201
189
|
total_component_E_D = 0.0
|
|
202
|
-
for
|
|
190
|
+
for _component_name, component in self.components.items():
|
|
203
191
|
if component.__class__.__name__ == "CycleCloser":
|
|
204
192
|
continue
|
|
205
193
|
else:
|
|
@@ -208,20 +196,22 @@ class ExergyAnalysis:
|
|
|
208
196
|
# Safely calculate y and y* avoiding division by zero
|
|
209
197
|
if self.E_F != 0:
|
|
210
198
|
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
|
|
199
|
+
component.y_star = component.E_D / self.E_D if component.E_D is not None else np.nan
|
|
212
200
|
else:
|
|
213
|
-
component.y =
|
|
214
|
-
component.y_star =
|
|
201
|
+
component.y = np.nan
|
|
202
|
+
component.y_star = np.nan
|
|
215
203
|
# Sum component destruction if available
|
|
216
|
-
if component.E_D is not
|
|
204
|
+
if component.E_D is not np.nan:
|
|
217
205
|
total_component_E_D += component.E_D
|
|
218
206
|
|
|
219
207
|
# Check if the sum of all component exergy destructions matches the overall system exergy destruction
|
|
220
208
|
if not np.isclose(total_component_E_D, self.E_D, rtol=1e-5):
|
|
221
|
-
logging.warning(
|
|
222
|
-
|
|
209
|
+
logging.warning(
|
|
210
|
+
f"Sum of component exergy destructions ({total_component_E_D:.2f} W) "
|
|
211
|
+
f"does not match overall system exergy destruction ({self.E_D:.2f} W)."
|
|
212
|
+
)
|
|
223
213
|
else:
|
|
224
|
-
logging.info(
|
|
214
|
+
logging.info("Exergy destruction check passed: Sum of component E_D matches overall E_D.")
|
|
225
215
|
|
|
226
216
|
@classmethod
|
|
227
217
|
def from_tespy(cls, model: str, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
|
|
@@ -247,22 +237,19 @@ class ExergyAnalysis:
|
|
|
247
237
|
"""
|
|
248
238
|
from tespy.networks import Network
|
|
249
239
|
|
|
250
|
-
from .parser.from_tespy.
|
|
240
|
+
from .parser.from_tespy.tespy_parser import to_exerpy
|
|
251
241
|
|
|
252
242
|
if isinstance(model, str):
|
|
253
243
|
model = Network.from_json(model)
|
|
254
244
|
elif isinstance(model, Network):
|
|
255
245
|
pass
|
|
256
246
|
else:
|
|
257
|
-
msg =
|
|
258
|
-
"Model parameter must be a path to a valid tespy network "
|
|
259
|
-
"export or a tespy network"
|
|
260
|
-
)
|
|
247
|
+
msg = "Model parameter must be a path to a valid tespy network " "export or a tespy network"
|
|
261
248
|
raise TypeError(msg)
|
|
262
249
|
|
|
263
|
-
data =
|
|
250
|
+
data = to_exerpy(model, Tamb, pamb)
|
|
264
251
|
data, Tamb, pamb = _process_json(data, Tamb, pamb, chemExLib, split_physical_exergy)
|
|
265
|
-
return cls(data[
|
|
252
|
+
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
266
253
|
|
|
267
254
|
@classmethod
|
|
268
255
|
def from_aspen(cls, path, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True):
|
|
@@ -272,7 +259,7 @@ class ExergyAnalysis:
|
|
|
272
259
|
Parameters
|
|
273
260
|
----------
|
|
274
261
|
path : str
|
|
275
|
-
Path to the
|
|
262
|
+
Path to the Aspen file (.bkp format).
|
|
276
263
|
Tamb : float, optional
|
|
277
264
|
Ambient temperature for analysis, default is None.
|
|
278
265
|
pamb : float, optional
|
|
@@ -285,7 +272,7 @@ class ExergyAnalysis:
|
|
|
285
272
|
Returns
|
|
286
273
|
-------
|
|
287
274
|
ExergyAnalysis
|
|
288
|
-
An instance of the ExergyAnalysis class with parsed
|
|
275
|
+
An instance of the ExergyAnalysis class with parsed Aspen data.
|
|
289
276
|
"""
|
|
290
277
|
|
|
291
278
|
from .parser.from_aspen import aspen_parser as aspen_parser
|
|
@@ -293,21 +280,22 @@ class ExergyAnalysis:
|
|
|
293
280
|
# Check if the file is an Aspen file
|
|
294
281
|
_, file_extension = os.path.splitext(path)
|
|
295
282
|
|
|
296
|
-
if file_extension ==
|
|
297
|
-
logging.info("Running
|
|
283
|
+
if file_extension == ".bkp":
|
|
284
|
+
logging.info("Running Aspen parsing and generating JSON data.")
|
|
298
285
|
data = aspen_parser.run_aspen(path, split_physical_exergy=split_physical_exergy)
|
|
299
|
-
logging.info("
|
|
286
|
+
logging.info("Parsing completed successfully.")
|
|
300
287
|
|
|
301
288
|
else:
|
|
302
289
|
# 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
|
-
)
|
|
290
|
+
raise ValueError(f"Unsupported file format: {file_extension}. Please provide " "an Aspen (.bkp) file.")
|
|
307
291
|
|
|
308
292
|
data, Tamb, pamb = _process_json(
|
|
309
|
-
data,
|
|
310
|
-
|
|
293
|
+
data,
|
|
294
|
+
Tamb=Tamb,
|
|
295
|
+
pamb=pamb,
|
|
296
|
+
chemExLib=chemExLib,
|
|
297
|
+
split_physical_exergy=split_physical_exergy,
|
|
298
|
+
required_component_fields=["name", "type"],
|
|
311
299
|
)
|
|
312
300
|
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
313
301
|
|
|
@@ -340,21 +328,22 @@ class ExergyAnalysis:
|
|
|
340
328
|
# Check if the file is an Ebsilon file
|
|
341
329
|
_, file_extension = os.path.splitext(path)
|
|
342
330
|
|
|
343
|
-
if file_extension ==
|
|
331
|
+
if file_extension == ".ebs":
|
|
344
332
|
logging.info("Running Ebsilon simulation and generating JSON data.")
|
|
345
333
|
data = ebs_parser.run_ebsilon(path, split_physical_exergy=split_physical_exergy)
|
|
346
334
|
logging.info("Simulation completed successfully.")
|
|
347
335
|
|
|
348
336
|
else:
|
|
349
337
|
# 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
|
-
)
|
|
338
|
+
raise ValueError(f"Unsupported file format: {file_extension}. Please provide " "an Ebsilon (.ebs) file.")
|
|
354
339
|
|
|
355
340
|
data, Tamb, pamb = _process_json(
|
|
356
|
-
data,
|
|
357
|
-
|
|
341
|
+
data,
|
|
342
|
+
Tamb=Tamb,
|
|
343
|
+
pamb=pamb,
|
|
344
|
+
chemExLib=chemExLib,
|
|
345
|
+
split_physical_exergy=split_physical_exergy,
|
|
346
|
+
required_component_fields=["name", "type", "type_index"],
|
|
358
347
|
)
|
|
359
348
|
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
360
349
|
|
|
@@ -392,7 +381,7 @@ class ExergyAnalysis:
|
|
|
392
381
|
data, Tamb, pamb = _process_json(
|
|
393
382
|
data, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy
|
|
394
383
|
)
|
|
395
|
-
return cls(data[
|
|
384
|
+
return cls(data["components"], data["connections"], Tamb, pamb, chemExLib, split_physical_exergy)
|
|
396
385
|
|
|
397
386
|
def exergy_results(self, print_results=True):
|
|
398
387
|
"""
|
|
@@ -412,8 +401,10 @@ class ExergyAnalysis:
|
|
|
412
401
|
(df_component_results, df_material_connection_results, df_non_material_connection_results)
|
|
413
402
|
with the exergy analysis results.
|
|
414
403
|
"""
|
|
404
|
+
|
|
415
405
|
# Define the lambda function for safe multiplication
|
|
416
|
-
convert
|
|
406
|
+
def convert(x, factor):
|
|
407
|
+
return x * factor if x is not None else None
|
|
417
408
|
|
|
418
409
|
# COMPONENTS
|
|
419
410
|
component_results = {
|
|
@@ -424,14 +415,14 @@ class ExergyAnalysis:
|
|
|
424
415
|
"E_L [kW]": [],
|
|
425
416
|
"ε [%]": [],
|
|
426
417
|
"y [%]": [],
|
|
427
|
-
"y* [%]": []
|
|
418
|
+
"y* [%]": [],
|
|
428
419
|
}
|
|
429
420
|
|
|
430
421
|
# Populate the dictionary with exergy analysis data from each component,
|
|
431
422
|
# excluding CycleCloser components.
|
|
432
423
|
for component_name, component in self.components.items():
|
|
433
424
|
# Exclude components whose class name is "CycleCloser"
|
|
434
|
-
if component.__class__.__name__ == "CycleCloser":
|
|
425
|
+
if component.__class__.__name__ == "CycleCloser" or component.__class__.__name__ == "PowerBus":
|
|
435
426
|
continue
|
|
436
427
|
|
|
437
428
|
component_results["Component"].append(component_name)
|
|
@@ -439,7 +430,9 @@ class ExergyAnalysis:
|
|
|
439
430
|
E_F_kW = convert(component.E_F, 1e-3)
|
|
440
431
|
E_P_kW = convert(component.E_P, 1e-3)
|
|
441
432
|
E_D_kW = convert(component.E_D, 1e-3)
|
|
442
|
-
E_L_kW =
|
|
433
|
+
E_L_kW = (
|
|
434
|
+
convert(getattr(component, "E_L", None), 1e-3) if getattr(component, "E_L", None) is not None else 0
|
|
435
|
+
)
|
|
443
436
|
epsilon_percent = convert(component.epsilon, 1e2)
|
|
444
437
|
|
|
445
438
|
component_results["E_F [kW]"].append(E_F_kW)
|
|
@@ -458,7 +451,7 @@ class ExergyAnalysis:
|
|
|
458
451
|
|
|
459
452
|
# Add the overall results to the components as dummy component "TOT"
|
|
460
453
|
df_component_results.loc["TOT", "E_F [kW]"] = convert(self.E_F, 1e-3)
|
|
461
|
-
df_component_results.loc["TOT", "Component"] =
|
|
454
|
+
df_component_results.loc["TOT", "Component"] = "TOT"
|
|
462
455
|
df_component_results.loc["TOT", "E_L [kW]"] = convert(self.E_L, 1e-3)
|
|
463
456
|
df_component_results.loc["TOT", "E_P [kW]"] = convert(self.E_P, 1e-3)
|
|
464
457
|
df_component_results.loc["TOT", "E_D [kW]"] = convert(self.E_D, 1e-3)
|
|
@@ -479,42 +472,57 @@ class ExergyAnalysis:
|
|
|
479
472
|
"e^PH [kJ/kg]": [],
|
|
480
473
|
"e^T [kJ/kg]": [],
|
|
481
474
|
"e^M [kJ/kg]": [],
|
|
482
|
-
"e^CH [kJ/kg]": []
|
|
475
|
+
"e^CH [kJ/kg]": [],
|
|
483
476
|
}
|
|
484
477
|
|
|
485
478
|
# NON-MATERIAL CONNECTIONS
|
|
486
|
-
non_material_connection_results = {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
"Exergy Flow [kW]": []
|
|
491
|
-
}
|
|
479
|
+
non_material_connection_results = {"Connection": [], "Kind": [], "Energy Flow [kW]": [], "Exergy Flow [kW]": []}
|
|
480
|
+
|
|
481
|
+
# Create set of valid component names
|
|
482
|
+
valid_components = {comp.name for comp in self.components.values()}
|
|
492
483
|
|
|
493
484
|
# Populate the dictionaries with exergy analysis data for each connection
|
|
494
485
|
for conn_name, conn_data in self.connections.items():
|
|
486
|
+
|
|
487
|
+
# Filter: only include connections that have source OR target in self.components
|
|
488
|
+
is_part_of_the_system = (
|
|
489
|
+
conn_data.get("source_component") in valid_components
|
|
490
|
+
or conn_data.get("target_component") in valid_components
|
|
491
|
+
)
|
|
492
|
+
if not is_part_of_the_system:
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
495
|
# Separate material and non-material connections based on fluid type
|
|
496
496
|
kind = conn_data.get("kind", None)
|
|
497
497
|
|
|
498
498
|
# Check if the connection is a non-material energy flow type
|
|
499
|
-
if kind in {
|
|
499
|
+
if kind in {"power", "heat"}:
|
|
500
500
|
# Non-material connections: only record energy flow, converted to kW using lambda
|
|
501
501
|
non_material_connection_results["Connection"].append(conn_name)
|
|
502
502
|
non_material_connection_results["Kind"].append(kind)
|
|
503
503
|
non_material_connection_results["Energy Flow [kW]"].append(convert(conn_data.get("energy_flow"), 1e-3))
|
|
504
504
|
non_material_connection_results["Exergy Flow [kW]"].append(convert(conn_data.get("E"), 1e-3))
|
|
505
|
-
elif kind ==
|
|
505
|
+
elif kind == "material":
|
|
506
506
|
# Material connections: record full data with conversions using lambda
|
|
507
507
|
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
|
-
|
|
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(
|
|
514
|
+
convert(conn_data.get("e_PH"), 1e-3)
|
|
515
|
+
) # Convert to kJ/kg
|
|
516
|
+
material_connection_results["e^T [kJ/kg]"].append(
|
|
517
|
+
convert(conn_data.get("e_T"), 1e-3)
|
|
518
|
+
) # Convert to kJ/kg
|
|
519
|
+
material_connection_results["e^M [kJ/kg]"].append(
|
|
520
|
+
convert(conn_data.get("e_M"), 1e-3)
|
|
521
|
+
) # Convert to kJ/kg
|
|
522
|
+
material_connection_results["e^CH [kJ/kg]"].append(
|
|
523
|
+
convert(conn_data.get("e_CH"), 1e-3)
|
|
524
|
+
) # Convert to kJ/kg
|
|
525
|
+
material_connection_results["E [kW]"].append(convert(conn_data.get("E"), 1e-3)) # Convert to kW
|
|
518
526
|
|
|
519
527
|
# Convert the material and non-material connection dictionaries into DataFrames
|
|
520
528
|
df_material_connection_results = pd.DataFrame(material_connection_results)
|
|
@@ -527,39 +535,55 @@ class ExergyAnalysis:
|
|
|
527
535
|
if print_results:
|
|
528
536
|
# Print the material connection results DataFrame in the console in a table format
|
|
529
537
|
print("\nMaterial Connection Exergy Analysis Results:")
|
|
530
|
-
print(
|
|
538
|
+
print(
|
|
539
|
+
tabulate(
|
|
540
|
+
df_material_connection_results.reset_index(drop=True),
|
|
541
|
+
headers="keys",
|
|
542
|
+
tablefmt="psql",
|
|
543
|
+
floatfmt=".3f",
|
|
544
|
+
)
|
|
545
|
+
)
|
|
531
546
|
|
|
532
547
|
# Print the non-material connection results DataFrame in the console in a table format
|
|
533
548
|
print("\nNon-Material Connection Exergy Analysis Results:")
|
|
534
|
-
print(
|
|
549
|
+
print(
|
|
550
|
+
tabulate(
|
|
551
|
+
df_non_material_connection_results.reset_index(drop=True),
|
|
552
|
+
headers="keys",
|
|
553
|
+
tablefmt="psql",
|
|
554
|
+
floatfmt=".3f",
|
|
555
|
+
)
|
|
556
|
+
)
|
|
535
557
|
|
|
536
558
|
# Print the component results DataFrame in the console in a table format
|
|
537
559
|
print("\nComponent Exergy Analysis Results:")
|
|
538
|
-
print(
|
|
560
|
+
print(
|
|
561
|
+
tabulate(df_component_results.reset_index(drop=True), headers="keys", tablefmt="psql", floatfmt=".3f")
|
|
562
|
+
)
|
|
539
563
|
|
|
540
564
|
return df_component_results, df_material_connection_results, df_non_material_connection_results
|
|
541
565
|
|
|
542
566
|
def export_to_json(self, output_path):
|
|
543
567
|
"""
|
|
544
568
|
Export the model to a JSON file.
|
|
545
|
-
|
|
569
|
+
|
|
546
570
|
Parameters
|
|
547
571
|
----------
|
|
548
572
|
output_path : str
|
|
549
573
|
Path where the JSON file will be saved.
|
|
550
|
-
|
|
574
|
+
|
|
551
575
|
Returns
|
|
552
576
|
-------
|
|
553
577
|
None
|
|
554
578
|
The model is saved to the specified path.
|
|
555
|
-
|
|
579
|
+
|
|
556
580
|
Notes
|
|
557
581
|
-----
|
|
558
582
|
This method serializes the model using the internal _serialize method
|
|
559
583
|
and writes the resulting data to a JSON file with indentation.
|
|
560
584
|
"""
|
|
561
585
|
data = self._serialize()
|
|
562
|
-
with open(output_path,
|
|
586
|
+
with open(output_path, "w") as json_file:
|
|
563
587
|
json.dump(data, json_file, indent=4)
|
|
564
588
|
logging.info(f"Model exported to JSON file: {output_path}.")
|
|
565
589
|
|
|
@@ -578,19 +602,69 @@ class ExergyAnalysis:
|
|
|
578
602
|
export = {}
|
|
579
603
|
export["components"] = self._component_data
|
|
580
604
|
export["connections"] = self._connection_data
|
|
581
|
-
export["ambient_conditions"] = {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
605
|
+
export["ambient_conditions"] = {"Tamb": self.Tamb, "Tamb_unit": "K", "pamb": self.pamb, "pamb_unit": "Pa"}
|
|
606
|
+
export["settings"] = {"split_physical_exergy": self.split_physical_exergy, "chemExLib": self.chemExLib}
|
|
607
|
+
|
|
608
|
+
# add per-component exergy results
|
|
609
|
+
for _comp_type, comps in export["components"].items():
|
|
610
|
+
for comp_name, comp_data in comps.items():
|
|
611
|
+
comp = self.components[comp_name]
|
|
612
|
+
comp_data["exergy_results"] = {
|
|
613
|
+
"E_F": getattr(comp, "E_F", None),
|
|
614
|
+
"E_P": getattr(comp, "E_P", None),
|
|
615
|
+
"E_D": getattr(comp, "E_D", None),
|
|
616
|
+
"epsilon": getattr(comp, "epsilon", None),
|
|
617
|
+
"y": getattr(comp, "y", None),
|
|
618
|
+
"y_star": getattr(comp, "y_star", None),
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
# add overall system exergy results
|
|
622
|
+
export["system_results"] = {
|
|
623
|
+
"E_F": getattr(self, "E_F", None),
|
|
624
|
+
"E_P": getattr(self, "E_P", None),
|
|
625
|
+
"E_D": getattr(self, "E_D", None),
|
|
626
|
+
"E_L": getattr(self, "E_L", None),
|
|
627
|
+
"epsilon": getattr(self, "epsilon", None),
|
|
590
628
|
}
|
|
591
629
|
|
|
592
630
|
return export
|
|
593
631
|
|
|
632
|
+
def _check_unaccounted_system_conns(self):
|
|
633
|
+
"""
|
|
634
|
+
Check if system boundary connections are not included in E_F, E_P, or E_L dictionaries.
|
|
635
|
+
"""
|
|
636
|
+
# Collect all accounted streams
|
|
637
|
+
accounted_streams = set()
|
|
638
|
+
for dictionary in [self.E_F_dict, self.E_P_dict, self.E_L_dict]:
|
|
639
|
+
accounted_streams.update(dictionary.get("inputs", []))
|
|
640
|
+
accounted_streams.update(dictionary.get("outputs", []))
|
|
641
|
+
|
|
642
|
+
# Identify actual system boundary connections
|
|
643
|
+
# A connection is at the boundary if source OR target is None/missing
|
|
644
|
+
system_boundary_conns = []
|
|
645
|
+
for conn_name, conn_data in self.connections.items():
|
|
646
|
+
source = conn_data.get("source_component", None)
|
|
647
|
+
target = conn_data.get("target_component", None)
|
|
648
|
+
if conn_data.get("source_component_type", None) == 1:
|
|
649
|
+
source = None
|
|
650
|
+
|
|
651
|
+
# Connection is at system boundary if one side is not connected
|
|
652
|
+
if source is None or target is None:
|
|
653
|
+
kind = conn_data.get("kind", "")
|
|
654
|
+
exergy = conn_data.get("E", 0)
|
|
655
|
+
# Only consider material/heat/power streams with significant exergy
|
|
656
|
+
if kind in ["material", "heat", "power"] and abs(exergy) > 1e-3:
|
|
657
|
+
system_boundary_conns.append(conn_name)
|
|
658
|
+
|
|
659
|
+
# Find unaccounted boundary connections
|
|
660
|
+
unaccounted = [conn for conn in system_boundary_conns if conn not in accounted_streams]
|
|
661
|
+
|
|
662
|
+
if unaccounted:
|
|
663
|
+
conn_list = ", ".join(f"'{conn}'" for conn in sorted(unaccounted))
|
|
664
|
+
logging.warning(
|
|
665
|
+
f"The following system boundary connections are not included in E_F, E_P, or E_L: {conn_list}"
|
|
666
|
+
)
|
|
667
|
+
|
|
594
668
|
|
|
595
669
|
def _construct_components(component_data, connection_data, Tamb):
|
|
596
670
|
"""
|
|
@@ -620,9 +694,9 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
620
694
|
for component_type, component_instances in component_data.items():
|
|
621
695
|
for component_name, component_information in component_instances.items():
|
|
622
696
|
# Skip components of type 'Splitter'
|
|
623
|
-
if component_type == "Splitter" or component_information.get('type') == "Splitter":
|
|
624
|
-
|
|
625
|
-
|
|
697
|
+
"""if component_type == "Splitter" or component_information.get('type') == "Splitter":
|
|
698
|
+
logging.info(f"Skipping 'Splitter' component during the exergy analysis: {component_name}")
|
|
699
|
+
continue # Skip this component"""
|
|
626
700
|
|
|
627
701
|
# Fetch the corresponding class from the registry using the component type
|
|
628
702
|
component_class = component_registry.items.get(component_type)
|
|
@@ -639,15 +713,15 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
639
713
|
component.outl = {}
|
|
640
714
|
|
|
641
715
|
# Assign streams to the components based on connection data
|
|
642
|
-
for
|
|
716
|
+
for _conn_id, conn_info in connection_data.items():
|
|
643
717
|
# Assign inlet streams
|
|
644
|
-
if conn_info[
|
|
645
|
-
target_connector_idx = conn_info[
|
|
718
|
+
if conn_info["target_component"] == component_name:
|
|
719
|
+
target_connector_idx = conn_info["target_connector"] # Use 0-based indexing
|
|
646
720
|
component.inl[target_connector_idx] = conn_info # Assign inlet stream
|
|
647
721
|
|
|
648
722
|
# Assign outlet streams
|
|
649
|
-
if conn_info[
|
|
650
|
-
source_connector_idx = conn_info[
|
|
723
|
+
if conn_info["source_component"] == component_name:
|
|
724
|
+
source_connector_idx = conn_info["source_connector"] # Use 0-based indexing
|
|
651
725
|
component.outl[source_connector_idx] = conn_info # Assign outlet stream
|
|
652
726
|
|
|
653
727
|
# --- NEW: Automatically mark Valve components as dissipative ---
|
|
@@ -663,7 +737,7 @@ def _construct_components(component_data, connection_data, Tamb):
|
|
|
663
737
|
else:
|
|
664
738
|
component.is_dissipative = False
|
|
665
739
|
except Exception as e:
|
|
666
|
-
logging.warning(f"Could not evaluate
|
|
740
|
+
logging.warning(f"Could not evaluate if Valve '{component_name}' is dissipative or not: {e}")
|
|
667
741
|
component.is_dissipative = False
|
|
668
742
|
else:
|
|
669
743
|
component.is_dissipative = False
|
|
@@ -698,15 +772,17 @@ def _load_json(json_path):
|
|
|
698
772
|
if not os.path.exists(json_path):
|
|
699
773
|
raise FileNotFoundError(f"File not found: {json_path}")
|
|
700
774
|
|
|
701
|
-
if not json_path.endswith(
|
|
775
|
+
if not json_path.endswith(".json"):
|
|
702
776
|
raise ValueError("File must have .json extension")
|
|
703
777
|
|
|
704
778
|
# Load and validate JSON
|
|
705
|
-
with open(json_path
|
|
779
|
+
with open(json_path) as file:
|
|
706
780
|
return json.load(file)
|
|
707
781
|
|
|
708
782
|
|
|
709
|
-
def _process_json(
|
|
783
|
+
def _process_json(
|
|
784
|
+
data, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True, required_component_fields=None
|
|
785
|
+
):
|
|
710
786
|
"""Process JSON data to prepare it for exergy analysis.
|
|
711
787
|
This function validates the data structure, ensures all required fields are present,
|
|
712
788
|
and enriches the data with chemical exergy and total exergy flow calculations.
|
|
@@ -734,29 +810,31 @@ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exe
|
|
|
734
810
|
If required sections or fields are missing, or if data structure is invalid
|
|
735
811
|
"""
|
|
736
812
|
# Validate required sections
|
|
737
|
-
|
|
813
|
+
if required_component_fields is None:
|
|
814
|
+
required_component_fields = ["name"]
|
|
815
|
+
required_sections = ["components", "connections", "ambient_conditions"]
|
|
738
816
|
missing_sections = [s for s in required_sections if s not in data]
|
|
739
817
|
if missing_sections:
|
|
740
818
|
raise ValueError(f"Missing required sections: {missing_sections}")
|
|
741
819
|
|
|
742
820
|
# Check for mass_composition in material streams if chemical exergy is requested
|
|
743
821
|
if chemExLib:
|
|
744
|
-
for conn_name, conn_data in data[
|
|
745
|
-
if conn_data.get(
|
|
822
|
+
for conn_name, conn_data in data["connections"].items():
|
|
823
|
+
if conn_data.get("kind") == "material" and "mass_composition" not in conn_data:
|
|
746
824
|
raise ValueError(f"Material stream '{conn_name}' missing mass_composition")
|
|
747
825
|
|
|
748
826
|
# Extract or use provided ambient conditions
|
|
749
|
-
Tamb = Tamb or data[
|
|
750
|
-
pamb = pamb or data[
|
|
827
|
+
Tamb = Tamb or data["ambient_conditions"].get("Tamb")
|
|
828
|
+
pamb = pamb or data["ambient_conditions"].get("pamb")
|
|
751
829
|
|
|
752
830
|
if Tamb is None or pamb is None:
|
|
753
831
|
raise ValueError("Ambient conditions (Tamb, pamb) must be provided either in JSON or as parameters")
|
|
754
832
|
|
|
755
833
|
# Validate component data structure
|
|
756
|
-
if not isinstance(data[
|
|
834
|
+
if not isinstance(data["components"], dict):
|
|
757
835
|
raise ValueError("Components section must be a dictionary")
|
|
758
836
|
|
|
759
|
-
for comp_type, components in data[
|
|
837
|
+
for comp_type, components in data["components"].items():
|
|
760
838
|
if not isinstance(components, dict):
|
|
761
839
|
raise ValueError(f"Component type '{comp_type}' must contain dictionary of components")
|
|
762
840
|
|
|
@@ -766,8 +844,8 @@ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exe
|
|
|
766
844
|
raise ValueError(f"Component '{comp_name}' missing required fields: {missing_fields}")
|
|
767
845
|
|
|
768
846
|
# Validate connection data structure
|
|
769
|
-
for conn_name, conn_data in data[
|
|
770
|
-
required_conn_fields = [
|
|
847
|
+
for conn_name, conn_data in data["connections"].items():
|
|
848
|
+
required_conn_fields = ["kind", "source_component", "target_component"]
|
|
771
849
|
missing_fields = [f for f in required_conn_fields if f not in conn_data]
|
|
772
850
|
if missing_fields:
|
|
773
851
|
raise ValueError(f"Connection '{conn_name}' missing required fields: {missing_fields}")
|
|
@@ -787,7 +865,7 @@ def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exe
|
|
|
787
865
|
|
|
788
866
|
|
|
789
867
|
class ExergoeconomicAnalysis:
|
|
790
|
-
""""
|
|
868
|
+
""" "
|
|
791
869
|
This class performs exergoeconomic analysis on a previously completed exergy analysis.
|
|
792
870
|
It takes the results from an ExergyAnalysis instance and builds upon them
|
|
793
871
|
to conduct a complete exergoeconomic analysis. It constructs and solves a system
|
|
@@ -872,12 +950,12 @@ class ExergoeconomicAnalysis:
|
|
|
872
950
|
|
|
873
951
|
This method assigns unique indices to each cost variable in the matrix system
|
|
874
952
|
and populates a dictionary mapping these indices to variable names.
|
|
875
|
-
|
|
953
|
+
|
|
876
954
|
For material streams, separate indices are assigned for thermal, mechanical,
|
|
877
955
|
and chemical exergy components when chemical exergy is enabled (otherwise only
|
|
878
956
|
thermal and mechanical). For non-material streams (heat, power), a single index
|
|
879
957
|
is assigned for the total exergy cost.
|
|
880
|
-
|
|
958
|
+
|
|
881
959
|
Notes
|
|
882
960
|
-----
|
|
883
961
|
The assigned indices are used for constructing the cost balance equations
|
|
@@ -889,8 +967,9 @@ class ExergoeconomicAnalysis:
|
|
|
889
967
|
# Process each connection (stream) which is part of the system (has a valid source or target)
|
|
890
968
|
for name, conn in self.connections.items():
|
|
891
969
|
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
|
-
|
|
970
|
+
is_part_of_the_system = (conn.get("source_component") in valid_components) or (
|
|
971
|
+
conn.get("target_component") in valid_components
|
|
972
|
+
)
|
|
894
973
|
if not is_part_of_the_system:
|
|
895
974
|
continue
|
|
896
975
|
else:
|
|
@@ -898,21 +977,14 @@ class ExergoeconomicAnalysis:
|
|
|
898
977
|
# For material streams, assign indices based on the flag.
|
|
899
978
|
if kind == "material":
|
|
900
979
|
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"
|
|
980
|
+
conn["CostVar_index"] = {"T": col_number, "M": col_number + 1, "CH": col_number + 2}
|
|
981
|
+
self.variables[str(col_number)] = f"C_{name}_T"
|
|
907
982
|
self.variables[str(col_number + 1)] = f"C_{name}_M"
|
|
908
983
|
self.variables[str(col_number + 2)] = f"C_{name}_CH"
|
|
909
984
|
col_number += 3
|
|
910
985
|
else:
|
|
911
|
-
conn["CostVar_index"] = {
|
|
912
|
-
|
|
913
|
-
"M": col_number + 1
|
|
914
|
-
}
|
|
915
|
-
self.variables[str(col_number)] = f"C_{name}_T"
|
|
986
|
+
conn["CostVar_index"] = {"T": col_number, "M": col_number + 1}
|
|
987
|
+
self.variables[str(col_number)] = f"C_{name}_T"
|
|
916
988
|
self.variables[str(col_number + 1)] = f"C_{name}_M"
|
|
917
989
|
col_number += 2
|
|
918
990
|
# Check if this connection's target is a dissipative component.
|
|
@@ -922,7 +994,7 @@ class ExergoeconomicAnalysis:
|
|
|
922
994
|
if comp is not None and getattr(comp, "is_dissipative", False):
|
|
923
995
|
# Add an extra index for the dissipative cost difference.
|
|
924
996
|
conn["CostVar_index"]["dissipative"] = col_number
|
|
925
|
-
self.variables[str(col_number)] = "
|
|
997
|
+
self.variables[str(col_number)] = f"dissipative_{comp.name}"
|
|
926
998
|
col_number += 1
|
|
927
999
|
# For non-material streams (e.g., heat, power), assign one index.
|
|
928
1000
|
elif kind in ("heat", "power"):
|
|
@@ -958,14 +1030,16 @@ class ExergoeconomicAnalysis:
|
|
|
958
1030
|
"""
|
|
959
1031
|
# --- Component Costs ---
|
|
960
1032
|
for comp_name, comp in self.components.items():
|
|
961
|
-
if isinstance(comp, CycleCloser):
|
|
1033
|
+
if isinstance(comp, CycleCloser | PowerBus):
|
|
962
1034
|
continue
|
|
963
1035
|
else:
|
|
964
1036
|
cost_key = f"{comp_name}_Z"
|
|
965
1037
|
if cost_key in Exe_Eco_Costs:
|
|
966
1038
|
comp.Z_costs = Exe_Eco_Costs[cost_key] / 3600 # Convert currency/h to currency/s
|
|
967
1039
|
else:
|
|
968
|
-
raise ValueError(
|
|
1040
|
+
raise ValueError(
|
|
1041
|
+
f"Cost for component '{comp_name}' is mandatory but not provided in Exe_Eco_Costs."
|
|
1042
|
+
)
|
|
969
1043
|
|
|
970
1044
|
# --- Connection Costs ---
|
|
971
1045
|
accepted_kinds = {"material", "heat", "power"}
|
|
@@ -983,7 +1057,9 @@ class ExergoeconomicAnalysis:
|
|
|
983
1057
|
|
|
984
1058
|
# For input connections (except for power connections) a cost is mandatory.
|
|
985
1059
|
if is_input and kind != "power" and cost_key not in Exe_Eco_Costs:
|
|
986
|
-
raise ValueError(
|
|
1060
|
+
raise ValueError(
|
|
1061
|
+
f"Cost for input connection '{conn_name}' is mandatory but not provided in Exe_Eco_Costs."
|
|
1062
|
+
)
|
|
987
1063
|
|
|
988
1064
|
# Assign cost if provided.
|
|
989
1065
|
if cost_key in Exe_Eco_Costs:
|
|
@@ -1012,23 +1088,22 @@ class ExergoeconomicAnalysis:
|
|
|
1012
1088
|
# Assign only the total cost for heat and power streams.
|
|
1013
1089
|
conn["C_TOT"] = c_TOT * conn["E"]
|
|
1014
1090
|
|
|
1015
|
-
|
|
1016
1091
|
def construct_matrix(self, Tamb):
|
|
1017
1092
|
"""
|
|
1018
1093
|
Construct the exergoeconomic cost matrix and vector.
|
|
1019
|
-
|
|
1094
|
+
|
|
1020
1095
|
Parameters
|
|
1021
1096
|
----------
|
|
1022
1097
|
Tamb : float
|
|
1023
1098
|
Ambient temperature in Kelvin.
|
|
1024
|
-
|
|
1099
|
+
|
|
1025
1100
|
Returns
|
|
1026
1101
|
-------
|
|
1027
1102
|
tuple
|
|
1028
1103
|
A tuple containing:
|
|
1029
1104
|
- A: numpy.ndarray - The coefficient matrix for the linear equation system
|
|
1030
1105
|
- b: numpy.ndarray - The right-hand side vector for the linear equation system
|
|
1031
|
-
|
|
1106
|
+
|
|
1032
1107
|
Notes
|
|
1033
1108
|
-----
|
|
1034
1109
|
This method constructs a system of linear equations that includes:
|
|
@@ -1038,9 +1113,8 @@ class ExergoeconomicAnalysis:
|
|
|
1038
1113
|
4. Custom auxiliary equations from each component
|
|
1039
1114
|
5. Special equations for dissipative components
|
|
1040
1115
|
"""
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
b = np.zeros(num_vars)
|
|
1116
|
+
self._A = np.zeros((self.num_variables, self.num_variables))
|
|
1117
|
+
self._b = np.zeros(self.num_variables)
|
|
1044
1118
|
counter = 0
|
|
1045
1119
|
|
|
1046
1120
|
# Filter out CycleCloser instances, keeping the component objects.
|
|
@@ -1050,26 +1124,26 @@ class ExergoeconomicAnalysis:
|
|
|
1050
1124
|
|
|
1051
1125
|
# 1. Cost balance equations for productive components.
|
|
1052
1126
|
for comp in valid_components:
|
|
1053
|
-
if
|
|
1054
|
-
|
|
1127
|
+
if (
|
|
1128
|
+
not getattr(comp, "is_dissipative", False)
|
|
1129
|
+
and not isinstance(comp, Splitter)
|
|
1130
|
+
and not isinstance(comp, PowerBus)
|
|
1131
|
+
):
|
|
1132
|
+
# Assign the row index for the cost balance equation to this component.
|
|
1055
1133
|
comp.exergy_cost_line = counter
|
|
1056
1134
|
for conn in self.connections.values():
|
|
1057
1135
|
# Check if the connection is linked to a valid component.
|
|
1058
1136
|
# If the connection's target is the component, it is an inlet (add +1).
|
|
1059
1137
|
if conn.get("target_component") == comp.name:
|
|
1060
|
-
for
|
|
1061
|
-
|
|
1138
|
+
for _key, col in conn["CostVar_index"].items():
|
|
1139
|
+
self._A[counter, col] = 1 # Incoming costs
|
|
1062
1140
|
# If the connection's source is the component, it is an outlet (subtract -1).
|
|
1063
1141
|
elif conn.get("source_component") == comp.name:
|
|
1064
|
-
for
|
|
1065
|
-
|
|
1066
|
-
self.equations[counter] =
|
|
1142
|
+
for _key, col in conn["CostVar_index"].items():
|
|
1143
|
+
self._A[counter, col] = -1 # Outgoing costs
|
|
1144
|
+
self.equations[counter] = {"kind": "cost_balance", "object": [comp.name], "property": "Z_costs"}
|
|
1067
1145
|
|
|
1068
|
-
|
|
1069
|
-
if getattr(comp, "is_dissipative", False):
|
|
1070
|
-
continue
|
|
1071
|
-
else:
|
|
1072
|
-
b[counter] = -getattr(comp, "Z_costs", 1)
|
|
1146
|
+
self._b[counter] = -getattr(comp, "Z_costs", 1)
|
|
1073
1147
|
counter += 1
|
|
1074
1148
|
|
|
1075
1149
|
# 2. Inlet stream equations.
|
|
@@ -1081,25 +1155,23 @@ class ExergoeconomicAnalysis:
|
|
|
1081
1155
|
for name, conn in self.connections.items():
|
|
1082
1156
|
# A connection is treated as an inlet if its source_component is missing or not part of the system
|
|
1083
1157
|
# 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
|
-
|
|
1158
|
+
if (conn.get("source_component") is None or conn.get("source_component") not in self.components) and (
|
|
1159
|
+
conn.get("target_component") in valid_component_names
|
|
1160
|
+
):
|
|
1086
1161
|
kind = conn.get("kind", "material")
|
|
1087
1162
|
if kind == "material":
|
|
1088
|
-
if self.chemical_exergy_enabled
|
|
1089
|
-
exergy_terms = ["T", "M", "CH"]
|
|
1090
|
-
else:
|
|
1091
|
-
exergy_terms = ["T", "M"]
|
|
1163
|
+
exergy_terms = ["T", "M", "CH"] if self.chemical_exergy_enabled else ["T", "M"]
|
|
1092
1164
|
for label in exergy_terms:
|
|
1093
1165
|
idx = conn["CostVar_index"][label]
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
self.equations[counter] = f"
|
|
1166
|
+
self._A[counter, idx] = 1 # Fix the cost variable.
|
|
1167
|
+
self._b[counter] = conn.get(f"C_{label}", conn.get("C_TOT", 0))
|
|
1168
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": f"c_{label}"}
|
|
1097
1169
|
counter += 1
|
|
1098
1170
|
elif kind == "heat":
|
|
1099
1171
|
idx = conn["CostVar_index"]["exergy"]
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
self.equations[counter] =
|
|
1172
|
+
self._A[counter, idx] = 1
|
|
1173
|
+
self._b[counter] = conn.get("C_TOT", 0)
|
|
1174
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": "c_TOT"}
|
|
1103
1175
|
counter += 1
|
|
1104
1176
|
elif kind == "power":
|
|
1105
1177
|
if not has_power_outlet:
|
|
@@ -1107,19 +1179,28 @@ class ExergoeconomicAnalysis:
|
|
|
1107
1179
|
if not conn.get("C_TOT"):
|
|
1108
1180
|
continue
|
|
1109
1181
|
idx = conn["CostVar_index"]["exergy"]
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
self.equations[counter] =
|
|
1182
|
+
self._A[counter, idx] = 1
|
|
1183
|
+
self._b[counter] = conn.get("C_TOT", 0)
|
|
1184
|
+
self.equations[counter] = {"kind": "boundary", "object": [name], "property": "c_TOT"}
|
|
1113
1185
|
counter += 1
|
|
1114
1186
|
else:
|
|
1115
1187
|
continue
|
|
1116
1188
|
|
|
1117
1189
|
# 3. Auxiliary equations for the equality of the specific costs
|
|
1118
1190
|
# of all power flows at the input or output of the system.
|
|
1119
|
-
power_conns = [
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1191
|
+
power_conns = [
|
|
1192
|
+
conn
|
|
1193
|
+
for conn in self.connections.values()
|
|
1194
|
+
if conn.get("kind") == "power"
|
|
1195
|
+
and (
|
|
1196
|
+
conn.get("source_component") not in valid_component_names
|
|
1197
|
+
or conn.get("target_component") not in valid_component_names
|
|
1198
|
+
)
|
|
1199
|
+
and not (
|
|
1200
|
+
conn.get("source_component") not in valid_component_names
|
|
1201
|
+
and conn.get("target_component") not in valid_component_names
|
|
1202
|
+
)
|
|
1203
|
+
]
|
|
1123
1204
|
|
|
1124
1205
|
# Only add auxiliary equations if there is more than one power connection.
|
|
1125
1206
|
if len(power_conns) > 1:
|
|
@@ -1128,10 +1209,14 @@ class ExergoeconomicAnalysis:
|
|
|
1128
1209
|
ref_idx = ref["CostVar_index"]["exergy"]
|
|
1129
1210
|
for conn in power_conns[1:]:
|
|
1130
1211
|
cur_idx = conn["CostVar_index"]["exergy"]
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
self.equations[counter] =
|
|
1212
|
+
self._A[counter, ref_idx] = 1 / ref["E"] if ref["E"] != 0 else 1
|
|
1213
|
+
self._A[counter, cur_idx] = -1 / conn["E"] if conn["E"] != 0 else -1
|
|
1214
|
+
self._b[counter] = 0
|
|
1215
|
+
self.equations[counter] = {
|
|
1216
|
+
"kind": "aux_power_eq",
|
|
1217
|
+
"objects": [ref["name"], conn["name"]],
|
|
1218
|
+
"property": "c_TOT",
|
|
1219
|
+
}
|
|
1135
1220
|
counter += 1
|
|
1136
1221
|
|
|
1137
1222
|
# 4. Auxiliary equations.
|
|
@@ -1144,7 +1229,9 @@ class ExergoeconomicAnalysis:
|
|
|
1144
1229
|
if hasattr(comp, "aux_eqs") and callable(comp.aux_eqs):
|
|
1145
1230
|
# The aux_eqs function should accept the current matrix, vector, counter, and Tamb,
|
|
1146
1231
|
# and return the updated (A, b, counter).
|
|
1147
|
-
|
|
1232
|
+
self._A, self._b, counter, self.equations = comp.aux_eqs(
|
|
1233
|
+
self._A, self._b, counter, Tamb, self.equations, self.chemical_exergy_enabled
|
|
1234
|
+
)
|
|
1148
1235
|
else:
|
|
1149
1236
|
# If no auxiliary equations are provided.
|
|
1150
1237
|
logging.warning(f"No auxiliary equations provided for component '{comp.name}'.")
|
|
@@ -1154,34 +1241,38 @@ class ExergoeconomicAnalysis:
|
|
|
1154
1241
|
# This will build an equation that integrates the dissipative cost difference (C_diff)
|
|
1155
1242
|
# into the overall cost balance (i.e. it charges the component’s Z_costs accordingly).
|
|
1156
1243
|
for comp in self.components.values():
|
|
1157
|
-
if getattr(comp, "is_dissipative", False):
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1244
|
+
if getattr(comp, "is_dissipative", False) and hasattr(comp, "dis_eqs") and callable(comp.dis_eqs):
|
|
1245
|
+
# Let the component provide its own modifications for the cost matrix.
|
|
1246
|
+
self._A, self._b, counter, self.equations = comp.dis_eqs(
|
|
1247
|
+
self._A,
|
|
1248
|
+
self._b,
|
|
1249
|
+
counter,
|
|
1250
|
+
Tamb,
|
|
1251
|
+
self.equations,
|
|
1252
|
+
self.chemical_exergy_enabled,
|
|
1253
|
+
list(self.components.values()),
|
|
1254
|
+
)
|
|
1164
1255
|
|
|
1165
1256
|
def solve_exergoeconomic_analysis(self, Tamb):
|
|
1166
1257
|
"""
|
|
1167
1258
|
Solve the exergoeconomic cost balance equations and assign the results to connections and components.
|
|
1168
|
-
|
|
1259
|
+
|
|
1169
1260
|
Parameters
|
|
1170
1261
|
----------
|
|
1171
1262
|
Tamb : float
|
|
1172
1263
|
Ambient temperature in Kelvin.
|
|
1173
|
-
|
|
1264
|
+
|
|
1174
1265
|
Returns
|
|
1175
1266
|
-------
|
|
1176
1267
|
tuple
|
|
1177
|
-
(exergy_cost_matrix, exergy_cost_vector) - The coefficient matrix and right-hand side vector used
|
|
1268
|
+
(exergy_cost_matrix, exergy_cost_vector) - The coefficient matrix and right-hand side vector used
|
|
1178
1269
|
in the linear equation system.
|
|
1179
|
-
|
|
1270
|
+
|
|
1180
1271
|
Raises
|
|
1181
1272
|
------
|
|
1182
1273
|
ValueError
|
|
1183
1274
|
If the exergoeconomic system is singular or if the cost balance is not satisfied.
|
|
1184
|
-
|
|
1275
|
+
|
|
1185
1276
|
Notes
|
|
1186
1277
|
-----
|
|
1187
1278
|
This method performs the following steps:
|
|
@@ -1193,29 +1284,40 @@ class ExergoeconomicAnalysis:
|
|
|
1193
1284
|
6. Computes system-level cost variables
|
|
1194
1285
|
"""
|
|
1195
1286
|
# Step 1: Construct the cost matrix
|
|
1196
|
-
|
|
1287
|
+
self.construct_matrix(Tamb)
|
|
1197
1288
|
|
|
1198
1289
|
# Step 2: Solve the system of equations
|
|
1199
1290
|
try:
|
|
1200
|
-
C_solution = np.linalg.solve(
|
|
1291
|
+
C_solution = np.linalg.solve(self._A, self._b)
|
|
1292
|
+
if np.isnan(C_solution).any():
|
|
1293
|
+
raise ValueError(
|
|
1294
|
+
"The solution of the cost matrix contains NaN values, indicating an issue with the cost balance equations or specifications."
|
|
1295
|
+
)
|
|
1201
1296
|
except np.linalg.LinAlgError:
|
|
1202
|
-
raise ValueError(
|
|
1203
|
-
|
|
1297
|
+
raise ValueError(
|
|
1298
|
+
f"Exergoeconomic system is singular and cannot be solved. "
|
|
1299
|
+
f"Provided equations: {len(self.equations)}, variables in system: {len(self.variables)}"
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
# Step 3: Distribute the cost differences of dissipative components to the serving components
|
|
1303
|
+
self.distribute_all_Z_diff(C_solution)
|
|
1204
1304
|
|
|
1205
|
-
# Step
|
|
1305
|
+
# Step 4: Assign solutions to connections
|
|
1206
1306
|
for conn_name, conn in self.connections.items():
|
|
1207
|
-
is_part_of_the_system =
|
|
1307
|
+
is_part_of_the_system = (
|
|
1308
|
+
conn.get("source_component") in self.components or conn.get("target_component") in self.components
|
|
1309
|
+
)
|
|
1208
1310
|
if not is_part_of_the_system:
|
|
1209
1311
|
continue
|
|
1210
1312
|
else:
|
|
1211
1313
|
kind = conn.get("kind")
|
|
1212
1314
|
if kind == "material":
|
|
1213
1315
|
# 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
|
|
1316
|
+
m_val = conn.get("m", 1) # mass flow [kg/s]
|
|
1317
|
+
e_T = conn.get("e_T", 0) # thermal specific exergy [kJ/kg]
|
|
1318
|
+
e_M = conn.get("e_M", 0) # mechanical specific exergy [kJ/kg]
|
|
1319
|
+
E_T = m_val * e_T # thermal exergy flow [kW]
|
|
1320
|
+
E_M = m_val * e_M # mechanical exergy flow [kW]
|
|
1219
1321
|
|
|
1220
1322
|
conn["C_T"] = C_solution[conn["CostVar_index"]["T"]]
|
|
1221
1323
|
conn["c_T"] = conn["C_T"] / E_T if E_T != 0 else np.nan
|
|
@@ -1227,8 +1329,8 @@ class ExergoeconomicAnalysis:
|
|
|
1227
1329
|
conn["c_PH"] = conn["C_PH"] / (E_T + E_M) if (E_T + E_M) != 0 else np.nan
|
|
1228
1330
|
|
|
1229
1331
|
if self.chemical_exergy_enabled:
|
|
1230
|
-
e_CH = conn.get("e_CH", 0)
|
|
1231
|
-
E_CH = m_val * e_CH
|
|
1332
|
+
e_CH = conn.get("e_CH", 0) # chemical specific exergy [kJ/kg]
|
|
1333
|
+
E_CH = m_val * e_CH # chemical exergy flow [kW]
|
|
1232
1334
|
conn["C_CH"] = C_solution[conn["CostVar_index"]["CH"]]
|
|
1233
1335
|
conn["c_CH"] = conn["C_CH"] / E_CH if E_CH != 0 else np.nan
|
|
1234
1336
|
conn["C_TOT"] = conn["C_T"] + conn["C_M"] + conn["C_CH"]
|
|
@@ -1242,12 +1344,12 @@ class ExergoeconomicAnalysis:
|
|
|
1242
1344
|
conn["C_TOT"] = C_solution[conn["CostVar_index"]["exergy"]]
|
|
1243
1345
|
conn["c_TOT"] = conn["C_TOT"] / conn.get("E", 1)
|
|
1244
1346
|
|
|
1245
|
-
# Step
|
|
1347
|
+
# Step 5: Assign C_P, C_F, C_D, and f values to components
|
|
1246
1348
|
for comp in self.exergy_analysis.components.values():
|
|
1247
1349
|
if hasattr(comp, "exergoeconomic_balance") and callable(comp.exergoeconomic_balance):
|
|
1248
|
-
comp.exergoeconomic_balance(self.exergy_analysis.Tamb)
|
|
1350
|
+
comp.exergoeconomic_balance(self.exergy_analysis.Tamb, self.chemical_exergy_enabled)
|
|
1249
1351
|
|
|
1250
|
-
# Step
|
|
1352
|
+
# Step 6: Distribute the cost of loss streams to the product streams.
|
|
1251
1353
|
# For each loss stream (provided in E_L_dict), its C_TOT is distributed among the product streams (in E_P_dict)
|
|
1252
1354
|
# in proportion to their exergy (E). After the distribution the loss stream's C_TOT is set to zero.
|
|
1253
1355
|
loss_streams = self.E_L_dict.get("inputs", [])
|
|
@@ -1282,7 +1384,7 @@ class ExergoeconomicAnalysis:
|
|
|
1282
1384
|
# The cost of the loss streams are not set to zero to show
|
|
1283
1385
|
# them in the table, but they are attributed to the product streams.
|
|
1284
1386
|
|
|
1285
|
-
# Step
|
|
1387
|
+
# Step 7: Compute system-level cost variables using the E_F and E_P dictionaries.
|
|
1286
1388
|
# Compute total fuel cost (C_F_total) from fuel streams.
|
|
1287
1389
|
C_F_total = 0.0
|
|
1288
1390
|
for conn_name in self.E_F_dict.get("inputs", []):
|
|
@@ -1316,25 +1418,89 @@ class ExergoeconomicAnalysis:
|
|
|
1316
1418
|
Z_total *= 3600
|
|
1317
1419
|
|
|
1318
1420
|
# 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
|
-
}
|
|
1421
|
+
self.system_costs = {"C_F": float(C_F_total), "C_P": float(C_P_total), "Z": float(Z_total)}
|
|
1324
1422
|
|
|
1325
1423
|
# Check cost balance and raise error if violated
|
|
1326
1424
|
if abs(self.system_costs["C_P"] - self.system_costs["C_F"] - self.system_costs["Z"]) > 1e-4:
|
|
1327
1425
|
raise ValueError(
|
|
1328
|
-
f"Exergoeconomic cost balance not satisfied:
|
|
1329
|
-
f"
|
|
1426
|
+
f"Exergoeconomic cost balance of the entire system is not satisfied: \n"
|
|
1427
|
+
f"C_P = {self.system_costs['C_P']:.3f} and is not equal to \n"
|
|
1428
|
+
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"
|
|
1429
|
+
f"The problem may be caused by incorrect specifications of E_F, E_P, and E_L."
|
|
1330
1430
|
)
|
|
1331
1431
|
|
|
1332
|
-
|
|
1432
|
+
def distribute_all_Z_diff(self, C_solution):
|
|
1433
|
+
"""
|
|
1434
|
+
Distribute every dissipative cost-difference (the C_diff variables) among
|
|
1435
|
+
all components according to their `serving_weight`.
|
|
1436
|
+
|
|
1437
|
+
Parameters
|
|
1438
|
+
----------
|
|
1439
|
+
C_solution : array_like
|
|
1440
|
+
Solution vector of cost variables from the solved linear system.
|
|
1441
|
+
"""
|
|
1442
|
+
# find all indices of variables named "dissipative_*"
|
|
1443
|
+
diss_indices = [int(idx) for idx, name in self.variables.items() if name.startswith("dissipative_")]
|
|
1444
|
+
total_C_diff = sum(C_solution[i] for i in diss_indices)
|
|
1445
|
+
# assign to each component that got a serving_weight
|
|
1446
|
+
for comp in self.exergy_analysis.components.values():
|
|
1447
|
+
if hasattr(comp, "serving_weight"):
|
|
1448
|
+
comp.Z_diss = comp.serving_weight * total_C_diff
|
|
1449
|
+
|
|
1450
|
+
def check_cost_balance(self, tol=1e-6):
|
|
1451
|
+
"""
|
|
1452
|
+
Check the exergoeconomic cost balance for each component.
|
|
1453
|
+
|
|
1454
|
+
For each component, verify that
|
|
1455
|
+
sum of inlet C_TOT minus sum of outlet C_TOT plus component Z_costs
|
|
1456
|
+
equals zero within the given tolerance.
|
|
1457
|
+
|
|
1458
|
+
Parameters
|
|
1459
|
+
----------
|
|
1460
|
+
tol : float, optional
|
|
1461
|
+
Numerical tolerance for balance check (default is 1e-6).
|
|
1462
|
+
|
|
1463
|
+
Returns
|
|
1464
|
+
-------
|
|
1465
|
+
dict
|
|
1466
|
+
Mapping from component name to tuple (balance, is_balanced),
|
|
1467
|
+
where balance is the residual and is_balanced is True if
|
|
1468
|
+
|balance| <= tol.
|
|
1469
|
+
"""
|
|
1470
|
+
from .components.helpers.cycle_closer import CycleCloser
|
|
1471
|
+
|
|
1472
|
+
balances = {}
|
|
1473
|
+
for name, comp in self.exergy_analysis.components.items():
|
|
1474
|
+
if isinstance(comp, CycleCloser):
|
|
1475
|
+
continue
|
|
1476
|
+
inlet_sum = 0.0
|
|
1477
|
+
outlet_sum = 0.0
|
|
1478
|
+
for conn in self.connections.values():
|
|
1479
|
+
if conn.get("target_component") == name:
|
|
1480
|
+
inlet_sum += conn.get("C_TOT", 0) or 0
|
|
1481
|
+
if conn.get("source_component") == name:
|
|
1482
|
+
outlet_sum += conn.get("C_TOT", 0) or 0
|
|
1483
|
+
comp.C_in = inlet_sum
|
|
1484
|
+
comp.C_out = outlet_sum
|
|
1485
|
+
z_cost = getattr(comp, "Z_costs", 0)
|
|
1486
|
+
z_diss = getattr(comp, "Z_diss", 0)
|
|
1487
|
+
balance = inlet_sum - outlet_sum + z_cost + z_diss
|
|
1488
|
+
balances[name] = (balance, abs(balance) <= tol)
|
|
1489
|
+
|
|
1490
|
+
all_ok = all(flag for _, flag in balances.values())
|
|
1491
|
+
if all_ok:
|
|
1492
|
+
print("Everything is fine: all component cost balances are fulfilled.")
|
|
1493
|
+
else:
|
|
1494
|
+
for name, (bal, ok) in balances.items():
|
|
1495
|
+
if not ok:
|
|
1496
|
+
print(f"Balance for component {name} not fulfilled: residual = {bal:.6f}")
|
|
1497
|
+
|
|
1498
|
+
return balances
|
|
1333
1499
|
|
|
1334
1500
|
def run(self, Exe_Eco_Costs, Tamb):
|
|
1335
1501
|
"""
|
|
1336
1502
|
Execute the full exergoeconomic analysis.
|
|
1337
|
-
|
|
1503
|
+
|
|
1338
1504
|
Parameters
|
|
1339
1505
|
----------
|
|
1340
1506
|
Exe_Eco_Costs : dict
|
|
@@ -1343,7 +1509,7 @@ class ExergoeconomicAnalysis:
|
|
|
1343
1509
|
Format for connections: "<connection_name>_c": cost_value [currency/GJ]
|
|
1344
1510
|
Tamb : float
|
|
1345
1511
|
Ambient temperature in Kelvin.
|
|
1346
|
-
|
|
1512
|
+
|
|
1347
1513
|
Notes
|
|
1348
1514
|
-----
|
|
1349
1515
|
This method performs the complete exergoeconomic analysis by:
|
|
@@ -1354,8 +1520,109 @@ class ExergoeconomicAnalysis:
|
|
|
1354
1520
|
self.initialize_cost_variables()
|
|
1355
1521
|
self.assign_user_costs(Exe_Eco_Costs)
|
|
1356
1522
|
self.solve_exergoeconomic_analysis(Tamb)
|
|
1357
|
-
logging.info(
|
|
1523
|
+
logging.info("Exergoeconomic analysis completed successfully.")
|
|
1524
|
+
self.check_cost_balance()
|
|
1525
|
+
print("stop")
|
|
1358
1526
|
|
|
1527
|
+
def print_equations(self):
|
|
1528
|
+
"""
|
|
1529
|
+
Get mapping of equation indices to equation descriptions.
|
|
1530
|
+
|
|
1531
|
+
Returns
|
|
1532
|
+
-------
|
|
1533
|
+
dict
|
|
1534
|
+
Keys are equation indices (int), values are equation descriptions (str).
|
|
1535
|
+
"""
|
|
1536
|
+
return {i: self.equations[i] for i in sorted(self.equations)}
|
|
1537
|
+
|
|
1538
|
+
def print_variables(self):
|
|
1539
|
+
"""
|
|
1540
|
+
Get mapping of variable indices to variable names.
|
|
1541
|
+
|
|
1542
|
+
Returns
|
|
1543
|
+
-------
|
|
1544
|
+
dict
|
|
1545
|
+
Keys are variable indices (int), values are variable names (str).
|
|
1546
|
+
"""
|
|
1547
|
+
return {int(k): v for k, v in self.variables.items()}
|
|
1548
|
+
|
|
1549
|
+
def detect_linear_dependencies(self, tol_strict: float = 1e-12, tol_near: float = 1e-8):
|
|
1550
|
+
"""
|
|
1551
|
+
Scan A for zero-rows, zero-cols, exactly colinear equation pairs
|
|
1552
|
+
(error ≤ tol_strict), and near-colinear pairs (≤ tol_near but > tol_strict).
|
|
1553
|
+
"""
|
|
1554
|
+
A = self._A
|
|
1555
|
+
|
|
1556
|
+
# 1) empty rows/cols
|
|
1557
|
+
zero_rows = np.where((np.abs(A) < tol_strict).all(axis=1))[0].tolist()
|
|
1558
|
+
zero_cols = np.where((np.abs(A) < tol_strict).all(axis=0))[0].tolist()
|
|
1559
|
+
|
|
1560
|
+
# 2) norms once
|
|
1561
|
+
norms = np.linalg.norm(A, axis=1)
|
|
1562
|
+
|
|
1563
|
+
strict = []
|
|
1564
|
+
near = []
|
|
1565
|
+
n = A.shape[0]
|
|
1566
|
+
for i in range(n):
|
|
1567
|
+
for j in range(i + 1, n):
|
|
1568
|
+
ni, nj = norms[i], norms[j]
|
|
1569
|
+
if ni > tol_strict and nj > tol_strict:
|
|
1570
|
+
dot = float(np.dot(A[i], A[j]))
|
|
1571
|
+
diff = abs(dot - ni * nj)
|
|
1572
|
+
if diff <= tol_strict:
|
|
1573
|
+
strict.append((i, j))
|
|
1574
|
+
elif diff <= tol_near:
|
|
1575
|
+
near.append((i, j))
|
|
1576
|
+
|
|
1577
|
+
# drop any strict pairs from the near list
|
|
1578
|
+
near_only = [pair for pair in near if pair not in strict]
|
|
1579
|
+
|
|
1580
|
+
return {
|
|
1581
|
+
"zero_rows": zero_rows,
|
|
1582
|
+
"zero_columns": zero_cols,
|
|
1583
|
+
"colinear_equations_strict": strict,
|
|
1584
|
+
"colinear_equations_near_only": near_only,
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
def print_dependency_report(self, tol_strict: float = 1e-12, tol_near: float = 1e-8):
|
|
1588
|
+
"""
|
|
1589
|
+
Nicely print which equations or variables are under- or over-determined,
|
|
1590
|
+
distinguishing exact vs. near colinearities.
|
|
1591
|
+
"""
|
|
1592
|
+
deps = self.detect_linear_dependencies(tol_strict, tol_near)
|
|
1593
|
+
|
|
1594
|
+
# empty equations
|
|
1595
|
+
if deps["zero_rows"]:
|
|
1596
|
+
print("⚠ Equations with no variables:")
|
|
1597
|
+
for eq in deps["zero_rows"]:
|
|
1598
|
+
print(f" • Eq[{eq}]: {self.equations.get(eq)}")
|
|
1599
|
+
else:
|
|
1600
|
+
print("✓ No empty equations.")
|
|
1601
|
+
|
|
1602
|
+
# unused variables
|
|
1603
|
+
if deps["zero_columns"]:
|
|
1604
|
+
print("\n⚠ Variables never used in any equation:")
|
|
1605
|
+
for var in deps["zero_columns"]:
|
|
1606
|
+
name = self.variables.get(str(var))
|
|
1607
|
+
print(f" • Var[{var}]: {name}")
|
|
1608
|
+
else:
|
|
1609
|
+
print("✓ All variables appear in at least one equation.")
|
|
1610
|
+
|
|
1611
|
+
# exactly colinear
|
|
1612
|
+
if deps["colinear_equations_strict"]:
|
|
1613
|
+
print("\n⚠ Exactly colinear (redundant) equation pairs:")
|
|
1614
|
+
for i, j in deps["colinear_equations_strict"]:
|
|
1615
|
+
print(f" • Eq[{i}] {self.equations[i]!r} ≈ Eq[{j}] {self.equations[j]!r}")
|
|
1616
|
+
else:
|
|
1617
|
+
print("✓ No exactly colinear equation pairs detected.")
|
|
1618
|
+
|
|
1619
|
+
# near-colinear
|
|
1620
|
+
if deps["colinear_equations_near_only"]:
|
|
1621
|
+
print("\n⚠ Nearly colinear equation pairs (|dot−‖i‖‖j‖| ≤ tol_near):")
|
|
1622
|
+
for i, j in deps["colinear_equations_near_only"]:
|
|
1623
|
+
print(f" • Eq[{i}] {self.equations[i]!r} ≈? Eq[{j}] {self.equations[j]!r}")
|
|
1624
|
+
else:
|
|
1625
|
+
print("✓ No near-colinear equation pairs detected.")
|
|
1359
1626
|
|
|
1360
1627
|
def exergoeconomic_results(self, print_results=True):
|
|
1361
1628
|
"""
|
|
@@ -1388,7 +1655,7 @@ class ExergoeconomicAnalysis:
|
|
|
1388
1655
|
f_list = []
|
|
1389
1656
|
|
|
1390
1657
|
# Iterate over the component DataFrame rows. The "Component" column contains the key.
|
|
1391
|
-
for
|
|
1658
|
+
for _idx, row in df_comp.iterrows():
|
|
1392
1659
|
comp_name = row["Component"]
|
|
1393
1660
|
if comp_name != "TOT":
|
|
1394
1661
|
comp = self.components.get(comp_name, None)
|
|
@@ -1421,26 +1688,33 @@ class ExergoeconomicAnalysis:
|
|
|
1421
1688
|
df_comp[f"C_D [{self.currency}/h]"] = C_D_list
|
|
1422
1689
|
df_comp[f"Z [{self.currency}/h]"] = Z_cost_list
|
|
1423
1690
|
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[
|
|
1691
|
+
df_comp["f [%]"] = f_list
|
|
1692
|
+
df_comp["r [%]"] = r_list
|
|
1426
1693
|
|
|
1427
1694
|
# Update the TOT row with system-level values using .loc.
|
|
1428
1695
|
df_comp.loc["TOT", f"C_F [{self.currency}/h]"] = self.system_costs.get("C_F", np.nan)
|
|
1429
1696
|
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]"]
|
|
1697
|
+
df_comp.loc["TOT", f"Z [{self.currency}/h]"] = self.system_costs.get("Z", np.nan)
|
|
1431
1698
|
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]"]
|
|
1699
|
+
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] + df_comp.loc["TOT", f"Z [{self.currency}/h]"]
|
|
1434
1700
|
)
|
|
1435
1701
|
|
|
1436
1702
|
df_comp[f"c_F [{self.currency}/GJ]"] = df_comp[f"C_F [{self.currency}/h]"] / df_comp["E_F [kW]"] * 1e6 / 3600
|
|
1437
1703
|
df_comp[f"c_P [{self.currency}/GJ]"] = df_comp[f"C_P [{self.currency}/h]"] / df_comp["E_P [kW]"] * 1e6 / 3600
|
|
1438
1704
|
|
|
1439
|
-
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] =
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
df_comp.loc["TOT", f"
|
|
1443
|
-
|
|
1705
|
+
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] = (
|
|
1706
|
+
df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"] * df_comp.loc["TOT", "E_D [kW]"] / 1e6 * 3600
|
|
1707
|
+
)
|
|
1708
|
+
df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] = (
|
|
1709
|
+
df_comp.loc["TOT", f"C_D [{self.currency}/h]"] + df_comp.loc["TOT", f"Z [{self.currency}/h]"]
|
|
1710
|
+
)
|
|
1711
|
+
df_comp.loc["TOT", "f [%]"] = (
|
|
1712
|
+
df_comp.loc["TOT", f"Z [{self.currency}/h]"] / df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] * 100
|
|
1713
|
+
)
|
|
1714
|
+
df_comp.loc["TOT", "r [%]"] = (
|
|
1715
|
+
(df_comp.loc["TOT", f"c_P [{self.currency}/GJ]"] - df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"])
|
|
1716
|
+
/ df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"]
|
|
1717
|
+
) * 100
|
|
1444
1718
|
|
|
1445
1719
|
# -------------------------
|
|
1446
1720
|
# Add cost columns to material connections.
|
|
@@ -1450,15 +1724,31 @@ class ExergoeconomicAnalysis:
|
|
|
1450
1724
|
C_M_list = []
|
|
1451
1725
|
C_CH_list = []
|
|
1452
1726
|
C_TOT_list = []
|
|
1453
|
-
# Lowercase cost columns (in
|
|
1727
|
+
# Lowercase cost columns (in {currency}/GJ_ex)
|
|
1454
1728
|
c_T_list = []
|
|
1455
1729
|
c_M_list = []
|
|
1456
1730
|
c_CH_list = []
|
|
1457
1731
|
c_TOT_list = []
|
|
1458
1732
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1733
|
+
# Create set of valid component names
|
|
1734
|
+
valid_components = {comp.name for comp in self.components.values()}
|
|
1735
|
+
|
|
1736
|
+
for _idx, row in df_mat.iterrows():
|
|
1737
|
+
conn_name = row["Connection"]
|
|
1461
1738
|
conn_data = self.connections.get(conn_name, {})
|
|
1739
|
+
|
|
1740
|
+
# Verify connection is part of the system
|
|
1741
|
+
is_part_of_the_system = (
|
|
1742
|
+
conn_data.get("source_component") in valid_components
|
|
1743
|
+
or conn_data.get("target_component") in valid_components
|
|
1744
|
+
)
|
|
1745
|
+
if not is_part_of_the_system:
|
|
1746
|
+
# Skip this connection
|
|
1747
|
+
C_T_list.append(np.nan)
|
|
1748
|
+
C_M_list.append(np.nan)
|
|
1749
|
+
# ... append nan for all other lists
|
|
1750
|
+
continue
|
|
1751
|
+
|
|
1462
1752
|
kind = conn_data.get("kind", None)
|
|
1463
1753
|
if kind == "material":
|
|
1464
1754
|
C_T = conn_data.get("C_T", None)
|
|
@@ -1503,17 +1793,17 @@ class ExergoeconomicAnalysis:
|
|
|
1503
1793
|
df_mat[f"C^M [{self.currency}/h]"] = C_M_list
|
|
1504
1794
|
df_mat[f"C^CH [{self.currency}/h]"] = C_CH_list
|
|
1505
1795
|
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 [
|
|
1796
|
+
df_mat[f"c^T [{self.currency}/GJ_ex]"] = c_T_list
|
|
1797
|
+
df_mat[f"c^M [{self.currency}/GJ_ex]"] = c_M_list
|
|
1798
|
+
df_mat[f"c^CH [{self.currency}/GJ_ex]"] = c_CH_list
|
|
1799
|
+
df_mat[f"c^TOT [{self.currency}/GJ_ex]"] = c_TOT_list
|
|
1510
1800
|
|
|
1511
1801
|
# -------------------------
|
|
1512
1802
|
# Add cost columns to non-material connections.
|
|
1513
1803
|
# -------------------------
|
|
1514
1804
|
C_TOT_non_mat = []
|
|
1515
1805
|
c_TOT_non_mat = []
|
|
1516
|
-
for
|
|
1806
|
+
for _idx, row in df_non_mat.iterrows():
|
|
1517
1807
|
conn_name = row["Connection"]
|
|
1518
1808
|
conn_data = self.connections.get(conn_name, {})
|
|
1519
1809
|
C_TOT = conn_data.get("C_TOT", None)
|
|
@@ -1521,48 +1811,52 @@ class ExergoeconomicAnalysis:
|
|
|
1521
1811
|
c_TOT = conn_data.get("c_TOT", None)
|
|
1522
1812
|
c_TOT_non_mat.append(c_TOT * 1e9 if c_TOT is not None else None)
|
|
1523
1813
|
df_non_mat[f"C^TOT [{self.currency}/h]"] = C_TOT_non_mat
|
|
1524
|
-
df_non_mat[f"c^TOT [
|
|
1814
|
+
df_non_mat[f"c^TOT [{self.currency}/GJ_ex]"] = c_TOT_non_mat
|
|
1525
1815
|
|
|
1526
1816
|
# -------------------------
|
|
1527
1817
|
# Split the material connections into two tables according to your specifications.
|
|
1528
1818
|
# -------------------------
|
|
1529
1819
|
# 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
|
-
|
|
1820
|
+
df_mat1 = df_mat[
|
|
1821
|
+
[
|
|
1822
|
+
"Connection",
|
|
1823
|
+
"m [kg/s]",
|
|
1824
|
+
"T [°C]",
|
|
1825
|
+
"p [bar]",
|
|
1826
|
+
"h [kJ/kg]",
|
|
1827
|
+
"s [J/kgK]",
|
|
1828
|
+
"E [kW]",
|
|
1829
|
+
"e^PH [kJ/kg]",
|
|
1830
|
+
"e^T [kJ/kg]",
|
|
1831
|
+
"e^M [kJ/kg]",
|
|
1832
|
+
"e^CH [kJ/kg]",
|
|
1833
|
+
]
|
|
1834
|
+
].copy()
|
|
1543
1835
|
|
|
1544
1836
|
# 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
|
-
|
|
1837
|
+
df_mat2 = df_mat[
|
|
1838
|
+
[
|
|
1839
|
+
"Connection",
|
|
1840
|
+
"E [kW]",
|
|
1841
|
+
"e^PH [kJ/kg]",
|
|
1842
|
+
"e^T [kJ/kg]",
|
|
1843
|
+
"e^M [kJ/kg]",
|
|
1844
|
+
"e^CH [kJ/kg]",
|
|
1845
|
+
f"C^T [{self.currency}/h]",
|
|
1846
|
+
f"C^M [{self.currency}/h]",
|
|
1847
|
+
f"C^CH [{self.currency}/h]",
|
|
1848
|
+
f"C^TOT [{self.currency}/h]",
|
|
1849
|
+
f"c^T [{self.currency}/GJ_ex]",
|
|
1850
|
+
f"c^M [{self.currency}/GJ_ex]",
|
|
1851
|
+
f"c^CH [{self.currency}/GJ_ex]",
|
|
1852
|
+
f"c^TOT [{self.currency}/GJ_ex]",
|
|
1853
|
+
]
|
|
1854
|
+
].copy()
|
|
1561
1855
|
|
|
1562
1856
|
# 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=
|
|
1857
|
+
df_mat1.dropna(axis=1, how="all", inplace=True)
|
|
1858
|
+
df_mat2.dropna(axis=1, how="all", inplace=True)
|
|
1859
|
+
df_non_mat.dropna(axis=1, how="all", inplace=True)
|
|
1566
1860
|
|
|
1567
1861
|
# -------------------------
|
|
1568
1862
|
# Print the four tables if requested.
|
|
@@ -1608,7 +1902,7 @@ class EconomicAnalysis:
|
|
|
1608
1902
|
def __init__(self, pars):
|
|
1609
1903
|
"""
|
|
1610
1904
|
Initialize the EconomicAnalysis with plant parameters provided in a dictionary.
|
|
1611
|
-
|
|
1905
|
+
|
|
1612
1906
|
Parameters
|
|
1613
1907
|
----------
|
|
1614
1908
|
pars : dict
|
|
@@ -1618,15 +1912,15 @@ class EconomicAnalysis:
|
|
|
1618
1912
|
- n: Lifetime of the plant (years)
|
|
1619
1913
|
- r_n: Nominal escalation rate (yearly based)
|
|
1620
1914
|
"""
|
|
1621
|
-
self.tau = pars[
|
|
1622
|
-
self.i_eff = pars[
|
|
1623
|
-
self.n = pars[
|
|
1624
|
-
self.r_n = pars[
|
|
1915
|
+
self.tau = pars["tau"]
|
|
1916
|
+
self.i_eff = pars["i_eff"]
|
|
1917
|
+
self.n = pars["n"]
|
|
1918
|
+
self.r_n = pars["r_n"]
|
|
1625
1919
|
|
|
1626
1920
|
def compute_crf(self):
|
|
1627
1921
|
"""
|
|
1628
1922
|
Compute the Capital Recovery Factor (CRF) using the effective rate of return.
|
|
1629
|
-
|
|
1923
|
+
|
|
1630
1924
|
Returns
|
|
1631
1925
|
-------
|
|
1632
1926
|
float
|
|
@@ -1636,12 +1930,12 @@ class EconomicAnalysis:
|
|
|
1636
1930
|
-----
|
|
1637
1931
|
CRF = i_eff * (1 + i_eff)**n / ((1 + i_eff)**n - 1)
|
|
1638
1932
|
"""
|
|
1639
|
-
return self.i_eff * (1 + self.i_eff)**self.n / ((1 + self.i_eff)**self.n - 1)
|
|
1933
|
+
return self.i_eff * (1 + self.i_eff) ** self.n / ((1 + self.i_eff) ** self.n - 1)
|
|
1640
1934
|
|
|
1641
1935
|
def compute_celf(self):
|
|
1642
1936
|
"""
|
|
1643
1937
|
Compute the Cost Escalation Levelization Factor (CELF) for repeating expenditures.
|
|
1644
|
-
|
|
1938
|
+
|
|
1645
1939
|
Returns
|
|
1646
1940
|
-------
|
|
1647
1941
|
float
|
|
@@ -1658,12 +1952,12 @@ class EconomicAnalysis:
|
|
|
1658
1952
|
def compute_levelized_investment_cost(self, total_PEC):
|
|
1659
1953
|
"""
|
|
1660
1954
|
Compute the levelized investment cost (annualized investment cost).
|
|
1661
|
-
|
|
1955
|
+
|
|
1662
1956
|
Parameters
|
|
1663
1957
|
----------
|
|
1664
1958
|
total_PEC : float
|
|
1665
1959
|
Total purchasing equipment cost (PEC) across all components.
|
|
1666
|
-
|
|
1960
|
+
|
|
1667
1961
|
Returns
|
|
1668
1962
|
-------
|
|
1669
1963
|
float
|
|
@@ -1674,14 +1968,14 @@ class EconomicAnalysis:
|
|
|
1674
1968
|
def compute_component_costs(self, PEC_list, OMC_relative):
|
|
1675
1969
|
"""
|
|
1676
1970
|
Compute the cost rates for each component.
|
|
1677
|
-
|
|
1971
|
+
|
|
1678
1972
|
Parameters
|
|
1679
1973
|
----------
|
|
1680
1974
|
PEC_list : list of float
|
|
1681
1975
|
The purchasing equipment cost (PEC) of each component (in currency).
|
|
1682
1976
|
OMC_relative : list of float
|
|
1683
1977
|
For each component, the first-year OM cost as a fraction of its PEC.
|
|
1684
|
-
|
|
1978
|
+
|
|
1685
1979
|
Returns
|
|
1686
1980
|
-------
|
|
1687
1981
|
tuple
|
|
@@ -1693,10 +1987,13 @@ class EconomicAnalysis:
|
|
|
1693
1987
|
total_PEC = sum(PEC_list)
|
|
1694
1988
|
# Levelize total investment cost and allocate proportionally.
|
|
1695
1989
|
levelized_investment_cost = self.compute_levelized_investment_cost(total_PEC)
|
|
1696
|
-
|
|
1990
|
+
if total_PEC == 0:
|
|
1991
|
+
Z_CC = [0 for _ in PEC_list]
|
|
1992
|
+
else:
|
|
1993
|
+
Z_CC = [(levelized_investment_cost * pec / total_PEC) / self.tau for pec in PEC_list]
|
|
1697
1994
|
|
|
1698
1995
|
# 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)]
|
|
1996
|
+
first_year_OMC = [frac * pec for frac, pec in zip(OMC_relative, PEC_list, strict=False)]
|
|
1700
1997
|
total_first_year_OMC = sum(first_year_OMC)
|
|
1701
1998
|
|
|
1702
1999
|
# Levelize the total operating and maintenance cost.
|
|
@@ -1704,8 +2001,11 @@ class EconomicAnalysis:
|
|
|
1704
2001
|
levelized_om_cost = total_first_year_OMC * celf_value
|
|
1705
2002
|
|
|
1706
2003
|
# Allocate the levelized OM cost to each component in proportion to its PEC.
|
|
1707
|
-
|
|
2004
|
+
if total_PEC == 0:
|
|
2005
|
+
Z_OM = [0 for _ in PEC_list]
|
|
2006
|
+
else:
|
|
2007
|
+
Z_OM = [(levelized_om_cost * pec / total_PEC) / self.tau for pec in PEC_list]
|
|
1708
2008
|
|
|
1709
2009
|
# 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
|
|
2010
|
+
Z_total = [zcc + zom for zcc, zom in zip(Z_CC, Z_OM, strict=False)]
|
|
2011
|
+
return Z_CC, Z_OM, Z_total
|