exerpy 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. exerpy/__init__.py +2 -4
  2. exerpy/analyses.py +849 -304
  3. exerpy/components/__init__.py +3 -0
  4. exerpy/components/combustion/base.py +53 -35
  5. exerpy/components/component.py +8 -8
  6. exerpy/components/heat_exchanger/base.py +188 -121
  7. exerpy/components/heat_exchanger/condenser.py +98 -62
  8. exerpy/components/heat_exchanger/simple.py +237 -137
  9. exerpy/components/heat_exchanger/steam_generator.py +46 -41
  10. exerpy/components/helpers/cycle_closer.py +61 -34
  11. exerpy/components/helpers/power_bus.py +117 -0
  12. exerpy/components/nodes/deaerator.py +176 -58
  13. exerpy/components/nodes/drum.py +50 -39
  14. exerpy/components/nodes/flash_tank.py +218 -43
  15. exerpy/components/nodes/mixer.py +249 -69
  16. exerpy/components/nodes/splitter.py +173 -0
  17. exerpy/components/nodes/storage.py +130 -0
  18. exerpy/components/piping/valve.py +311 -115
  19. exerpy/components/power_machines/generator.py +105 -38
  20. exerpy/components/power_machines/motor.py +111 -39
  21. exerpy/components/turbomachinery/compressor.py +214 -68
  22. exerpy/components/turbomachinery/pump.py +215 -68
  23. exerpy/components/turbomachinery/turbine.py +182 -74
  24. exerpy/cost_estimation/__init__.py +65 -0
  25. exerpy/cost_estimation/turton.py +1260 -0
  26. exerpy/data/cost_correlations/cepci_index.json +135 -0
  27. exerpy/data/cost_correlations/component_mapping.json +450 -0
  28. exerpy/data/cost_correlations/material_factors.json +428 -0
  29. exerpy/data/cost_correlations/pressure_factors.json +206 -0
  30. exerpy/data/cost_correlations/turton2008.json +726 -0
  31. exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_tables.pdf +0 -0
  32. exerpy/data/cost_correlations/turton2008_design_analysis_synthesis_components_theory.pdf +0 -0
  33. exerpy/functions.py +389 -264
  34. exerpy/parser/from_aspen/aspen_config.py +57 -48
  35. exerpy/parser/from_aspen/aspen_parser.py +373 -280
  36. exerpy/parser/from_ebsilon/__init__.py +2 -2
  37. exerpy/parser/from_ebsilon/check_ebs_path.py +15 -19
  38. exerpy/parser/from_ebsilon/ebsilon_config.py +328 -226
  39. exerpy/parser/from_ebsilon/ebsilon_functions.py +205 -38
  40. exerpy/parser/from_ebsilon/ebsilon_parser.py +392 -255
  41. exerpy/parser/from_ebsilon/utils.py +16 -11
  42. exerpy/parser/from_tespy/tespy_config.py +33 -1
  43. exerpy/parser/from_tespy/tespy_parser.py +151 -0
  44. {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/METADATA +43 -2
  45. exerpy-0.0.4.dist-info/RECORD +57 -0
  46. exerpy-0.0.2.dist-info/RECORD +0 -44
  47. {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/WHEEL +0 -0
  48. {exerpy-0.0.2.dist-info → exerpy-0.0.4.dist-info}/licenses/LICENSE +0 -0
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 .functions import add_chemical_exergy
12
- from .functions import add_total_exergy_flow
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={}) -> None:
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]['E']
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]['E']
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]['E']
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]['E']
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]['E']
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]['E']
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
- if self.epsilon is not None:
191
- eff_str = f"{self.epsilon:.2%}"
192
- else:
193
- eff_str = "N/A"
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 component_name, component in self.components.items():
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 None
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 = None
214
- component.y_star = None
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 None:
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(f"Sum of component exergy destructions ({total_component_E_D:.2f} W) "
222
- f"does not match overall system exergy destruction ({self.E_D:.2f} W).")
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(f"Exergy destruction check passed: Sum of component E_D matches overall E_D.")
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.tespy_config import EXERPY_TESPY_MAPPINGS
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 = model.to_exerpy(Tamb, pamb, EXERPY_TESPY_MAPPINGS)
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['components'], data['connections'], Tamb, pamb, chemExLib, split_physical_exergy)
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 Ebsilon file (.bkp format).
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 Ebsilon data.
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 == '.bkp':
297
- logging.info("Running Ebsilon simulation and generating JSON data.")
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("Simulation completed successfully.")
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, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy,
310
- required_component_fields=["name", "type"]
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 == '.ebs':
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, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy,
357
- required_component_fields=["name", "type", "type_index"]
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['components'], data['connections'], Tamb, pamb, chemExLib, split_physical_exergy)
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 = lambda x, factor: x * factor if x is not None else None
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 = convert(getattr(component, 'E_L', None), 1e-3) if getattr(component, 'E_L', None) is not None else 0
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"] = 'TOT'
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
- "Connection": [],
488
- "Kind": [],
489
- "Energy Flow [kW]": [],
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 {'power', 'heat'}:
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 == 'material':
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('m', None))
509
- material_connection_results["T [°C]"].append(conn_data.get('T') - 273.15) # Convert to °C
510
- material_connection_results["p [bar]"].append(convert(conn_data.get('p'), 1e-5)) # Convert Pa to bar
511
- material_connection_results["h [kJ/kg]"].append(convert(conn_data.get('h'), 1e-3)) # Convert to kJ/kg
512
- material_connection_results["s [J/kgK]"].append(conn_data.get('s', None))
513
- material_connection_results["e^PH [kJ/kg]"].append(convert(conn_data.get('e_PH'), 1e-3)) # Convert to kJ/kg
514
- material_connection_results["e^T [kJ/kg]"].append(convert(conn_data.get('e_T'), 1e-3)) # Convert to kJ/kg
515
- material_connection_results["e^M [kJ/kg]"].append(convert(conn_data.get('e_M'), 1e-3)) # Convert to kJ/kg
516
- material_connection_results["e^CH [kJ/kg]"].append(convert(conn_data.get('e_CH'), 1e-3)) # Convert to kJ/kg
517
- material_connection_results["E [kW]"].append(convert(conn_data.get("E"), 1e-3)) # Convert to kW
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(tabulate(df_material_connection_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
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(tabulate(df_non_material_connection_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
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(tabulate(df_component_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
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, 'w') as json_file:
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
- "Tamb": self.Tamb,
583
- "Tamb_unit": "K",
584
- "pamb": self.pamb,
585
- "pamb_unit": "Pa"
586
- }
587
- export["settings"] = {
588
- "split_physical_exergy": self.split_physical_exergy,
589
- "chemExLib": self.chemExLib
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
- logging.info(f"Skipping 'Splitter' component during the exergy analysis: {component_name}")
625
- continue # Skip this component
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 conn_id, conn_info in connection_data.items():
937
+ for _conn_id, conn_info in connection_data.items():
643
938
  # Assign inlet streams
644
- if conn_info['target_component'] == component_name:
645
- target_connector_idx = conn_info['target_connector'] # Use 0-based indexing
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['source_component'] == component_name:
650
- source_connector_idx = conn_info['source_connector'] # Use 0-based indexing
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 dissipativity for Valve '{component_name}': {e}")
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('.json'):
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, 'r') as file:
1007
+ with open(json_path) as file:
706
1008
  return json.load(file)
707
1009
 
708
1010
 
709
- def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True, required_component_fields=['name']):
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
- required_sections = ['components', 'connections', 'ambient_conditions']
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['connections'].items():
745
- if conn_data.get('kind') == 'material' and 'mass_composition' not in conn_data:
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['ambient_conditions'].get('Tamb')
750
- pamb = pamb or data['ambient_conditions'].get('pamb')
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['components'], dict):
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['components'].items():
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['connections'].items():
770
- required_conn_fields = ['kind', 'source_component', 'target_component']
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
- (conn.get("target_component") in valid_components)
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
- "T": col_number,
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
- "T": col_number,
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)] = "dissipative"
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(f"Cost for component '{comp_name}' is mandatory but not provided in Exe_Eco_Costs.")
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(f"Cost for input connection '{conn_name}' is mandatory but not provided in Exe_Eco_Costs.")
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
- num_vars = self.num_variables
1042
- A = np.zeros((num_vars, num_vars))
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 not getattr(comp, "is_dissipative", False):
1054
- # Assign the row index for the cost balance equation to this component.
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 key, col in conn["CostVar_index"].items():
1061
- A[counter, col] = 1 # Incoming costs
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 key, col in conn["CostVar_index"].items():
1065
- A[counter, col] = -1 # Outgoing costs
1066
- self.equations[counter] = f"Z_costs_{comp.name}" # Store the equation name
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
- # For productive components: C_in - C_out = -Z_costs.
1069
- if getattr(comp, "is_dissipative", False):
1070
- continue
1071
- else:
1072
- b[counter] = -getattr(comp, "Z_costs", 1)
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
- and (conn.get("target_component") in valid_component_names):
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
- A[counter, idx] = 1 # Fix the cost variable.
1095
- b[counter] = conn.get(f"C_{label}", conn.get("C_TOT", 0))
1096
- self.equations[counter] = f"boundary_stream_costs_{name}_{label}"
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
- A[counter, idx] = 1
1101
- b[counter] = conn.get("C_TOT", 0)
1102
- self.equations[counter] = f"boundary_stream_costs_{name}_TOT"
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
- A[counter, idx] = 1
1111
- b[counter] = conn.get("C_TOT", 0)
1112
- self.equations[counter] = f"boundary_stream_costs_{name}_TOT"
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
- continue
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 = [conn for conn in self.connections.values()
1120
- if conn.get("kind") == "power" and
1121
- (conn.get("source_component") not in valid_component_names or conn.get("target_component") not in valid_component_names) and not
1122
- (conn.get("source_component") not in valid_component_names and conn.get("target_component") not in valid_component_names)]
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
- A[counter, ref_idx] = 1 / ref["E"] if ref["E"] != 0 else 1
1132
- A[counter, cur_idx] = -1 / conn["E"] if conn["E"] != 0 else -1
1133
- b[counter] = 0
1134
- self.equations[counter] = f"aux_power_eq_{ref['name']}_{conn['name']}"
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
- A, b, counter, self.equations = comp.aux_eqs(A, b, counter, Tamb, self.equations, self.chemical_exergy_enabled)
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
- if hasattr(comp, "dis_eqs") and callable(comp.dis_eqs):
1159
- # Let the component provide its own modifications for the cost matrix.
1160
- A, b, counter, self.equations = comp.dis_eqs(A, b, counter, Tamb, self.equations, self.chemical_exergy_enabled, list(self.components.values()))
1161
-
1162
- return A, b
1163
-
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
- exergy_cost_matrix, exergy_cost_vector = self.construct_matrix(Tamb)
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(exergy_cost_matrix, exergy_cost_vector)
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(f"Exergoeconomic system is singular and cannot be solved. "
1203
- f"Provided equations: {len(self.equations)}, variables in system: {len(self.variables)}")
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 3: Assign solutions to connections
1541
+ # Step 4: Assign solutions to connections
1206
1542
  for conn_name, conn in self.connections.items():
1207
- is_part_of_the_system = conn.get("source_component") or conn.get("target_component")
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) # mass flow [kg/s]
1215
- e_T = conn.get("e_T", 0) # thermal specific exergy [kJ/kg]
1216
- e_M = conn.get("e_M", 0) # mechanical specific exergy [kJ/kg]
1217
- E_T = m_val * e_T # thermal exergy flow [kW]
1218
- E_M = m_val * e_M # mechanical exergy flow [kW]
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 np.nan
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 np.nan
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 np.nan
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) # chemical specific exergy [kJ/kg]
1231
- E_CH = m_val * e_CH # chemical exergy flow [kW]
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 np.nan
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 np.nan
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 np.nan
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 4: Assign C_P, C_F, C_D, and f values to components
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 5: Distribute the cost of loss streams to the product streams.
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 6: Compute system-level cost variables using the E_F and E_P dictionaries.
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: C_P ({self.system_costs['C_P']:.6f}) ≠ "
1329
- f"C_F ({self.system_costs['C_F']:.6f}) + Z ({self.system_costs['Z']:.6f})"
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
- return exergy_cost_matrix, exergy_cost_vector
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(f"Exergoeconomic analysis completed successfully.")
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 idx, row in df_comp.iterrows():
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[f"f [%]"] = f_list
1425
- df_comp[f"r [%]"] = r_list
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]"] = self.system_costs.get("Z", np.nan)
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]"] = df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"] * df_comp.loc["TOT", f"E_D [kW]"] / 1e6 * 3600
1440
- df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] = df_comp.loc["TOT", f"C_D [{self.currency}/h]"] + df_comp.loc["TOT", f"Z [{self.currency}/h]"]
1441
- df_comp.loc["TOT", f"f [%]"] = df_comp.loc["TOT", f"Z [{self.currency}/h]"] / df_comp.loc["TOT", f"C_D+Z [{self.currency}/h]"] * 100
1442
- df_comp.loc["TOT", f"r [%]"] = ((df_comp.loc["TOT", f"c_P [{self.currency}/GJ]"] - df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"]) /
1443
- df_comp.loc["TOT", f"c_F [{self.currency}/GJ]"]) * 100
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 GJ/{currency})
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
- for idx, row in df_mat.iterrows():
1460
- conn_name = row['Connection']
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 [GJ/{self.currency}]"] = c_T_list
1507
- df_mat[f"c^M [GJ/{self.currency}]"] = c_M_list
1508
- df_mat[f"c^CH [GJ/{self.currency}]"] = c_CH_list
1509
- df_mat[f"c^TOT [GJ/{self.currency}]"] = c_TOT_list
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 idx, row in df_non_mat.iterrows():
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 [GJ/{self.currency}]"] = c_TOT_non_mat
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
- "Connection",
1532
- "m [kg/s]",
1533
- "T [°C]",
1534
- "p [bar]",
1535
- "h [kJ/kg]",
1536
- "s [J/kgK]",
1537
- "E [kW]",
1538
- "e^PH [kJ/kg]",
1539
- "e^T [kJ/kg]",
1540
- "e^M [kJ/kg]",
1541
- "e^CH [kJ/kg]"
1542
- ]].copy()
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
- "Connection",
1547
- "E [kW]",
1548
- "e^PH [kJ/kg]",
1549
- "e^T [kJ/kg]",
1550
- "e^M [kJ/kg]",
1551
- "e^CH [kJ/kg]",
1552
- f"C^T [{self.currency}/h]",
1553
- f"C^M [{self.currency}/h]",
1554
- f"C^CH [{self.currency}/h]",
1555
- f"C^TOT [{self.currency}/h]",
1556
- f"c^T [GJ/{self.currency}]",
1557
- f"c^M [GJ/{self.currency}]",
1558
- f"c^CH [GJ/{self.currency}]",
1559
- f"c^TOT [GJ/{self.currency}]"
1560
- ]].copy()
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='all', inplace=True)
1564
- df_mat2.dropna(axis=1, how='all', inplace=True)
1565
- df_non_mat.dropna(axis=1, how='all', inplace=True)
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['tau']
1622
- self.i_eff = pars['i_eff']
1623
- self.n = pars['n']
1624
- self.r_n = pars['r_n']
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
- Z_CC = [(levelized_investment_cost * pec / total_PEC) / self.tau for pec in PEC_list]
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
- Z_OM = [(levelized_om_cost * pec / total_PEC) / self.tau for pec in PEC_list]
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