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.
Files changed (39) hide show
  1. exerpy/__init__.py +2 -4
  2. exerpy/analyses.py +597 -297
  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 +186 -119
  7. exerpy/components/heat_exchanger/condenser.py +96 -60
  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 +181 -63
  22. exerpy/components/turbomachinery/pump.py +182 -63
  23. exerpy/components/turbomachinery/turbine.py +182 -74
  24. exerpy/functions.py +388 -263
  25. exerpy/parser/from_aspen/aspen_config.py +57 -48
  26. exerpy/parser/from_aspen/aspen_parser.py +373 -280
  27. exerpy/parser/from_ebsilon/__init__.py +2 -2
  28. exerpy/parser/from_ebsilon/check_ebs_path.py +15 -19
  29. exerpy/parser/from_ebsilon/ebsilon_config.py +328 -226
  30. exerpy/parser/from_ebsilon/ebsilon_functions.py +205 -38
  31. exerpy/parser/from_ebsilon/ebsilon_parser.py +392 -255
  32. exerpy/parser/from_ebsilon/utils.py +16 -11
  33. exerpy/parser/from_tespy/tespy_config.py +32 -1
  34. exerpy/parser/from_tespy/tespy_parser.py +151 -0
  35. {exerpy-0.0.2.dist-info → exerpy-0.0.3.dist-info}/METADATA +43 -2
  36. exerpy-0.0.3.dist-info/RECORD +48 -0
  37. exerpy-0.0.2.dist-info/RECORD +0 -44
  38. {exerpy-0.0.2.dist-info → exerpy-0.0.3.dist-info}/WHEEL +0 -0
  39. {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 .functions import add_chemical_exergy
12
- from .functions import add_total_exergy_flow
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={}) -> None:
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]['E']
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]['E']
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]['E']
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]['E']
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]['E']
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]['E']
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
- if self.epsilon is not None:
191
- eff_str = f"{self.epsilon:.2%}"
192
- else:
193
- eff_str = "N/A"
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 component_name, component in self.components.items():
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 None
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 = None
214
- component.y_star = None
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 None:
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(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).")
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(f"Exergy destruction check passed: Sum of component E_D matches overall E_D.")
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.tespy_config import EXERPY_TESPY_MAPPINGS
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 = model.to_exerpy(Tamb, pamb, EXERPY_TESPY_MAPPINGS)
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['components'], data['connections'], Tamb, pamb, chemExLib, split_physical_exergy)
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 Ebsilon file (.bkp format).
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 Ebsilon data.
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 == '.bkp':
297
- logging.info("Running Ebsilon simulation and generating JSON data.")
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("Simulation completed successfully.")
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, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy,
310
- required_component_fields=["name", "type"]
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 == '.ebs':
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, Tamb=Tamb, pamb=pamb, chemExLib=chemExLib, split_physical_exergy=split_physical_exergy,
357
- required_component_fields=["name", "type", "type_index"]
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['components'], data['connections'], Tamb, pamb, chemExLib, split_physical_exergy)
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 = lambda x, factor: x * factor if x is not None else None
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 = convert(getattr(component, 'E_L', None), 1e-3) if getattr(component, 'E_L', None) is not None else 0
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"] = 'TOT'
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
- "Connection": [],
488
- "Kind": [],
489
- "Energy Flow [kW]": [],
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 {'power', 'heat'}:
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 == 'material':
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('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
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(tabulate(df_material_connection_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
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(tabulate(df_non_material_connection_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
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(tabulate(df_component_results.reset_index(drop=True), headers='keys', tablefmt='psql', floatfmt='.3f'))
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, 'w') as json_file:
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
- "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
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
- logging.info(f"Skipping 'Splitter' component during the exergy analysis: {component_name}")
625
- continue # Skip this component
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 conn_id, conn_info in connection_data.items():
716
+ for _conn_id, conn_info in connection_data.items():
643
717
  # Assign inlet streams
644
- if conn_info['target_component'] == component_name:
645
- target_connector_idx = conn_info['target_connector'] # Use 0-based indexing
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['source_component'] == component_name:
650
- source_connector_idx = conn_info['source_connector'] # Use 0-based indexing
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 dissipativity for Valve '{component_name}': {e}")
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('.json'):
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, 'r') as file:
779
+ with open(json_path) as file:
706
780
  return json.load(file)
707
781
 
708
782
 
709
- def _process_json(data, Tamb=None, pamb=None, chemExLib=None, split_physical_exergy=True, required_component_fields=['name']):
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
- required_sections = ['components', 'connections', 'ambient_conditions']
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['connections'].items():
745
- if conn_data.get('kind') == 'material' and 'mass_composition' not in conn_data:
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['ambient_conditions'].get('Tamb')
750
- pamb = pamb or data['ambient_conditions'].get('pamb')
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['components'], dict):
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['components'].items():
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['connections'].items():
770
- required_conn_fields = ['kind', 'source_component', 'target_component']
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
- (conn.get("target_component") in valid_components)
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
- "T": col_number,
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
- "T": col_number,
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)] = "dissipative"
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(f"Cost for component '{comp_name}' is mandatory but not provided in Exe_Eco_Costs.")
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(f"Cost for input connection '{conn_name}' is mandatory but not provided in Exe_Eco_Costs.")
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
- num_vars = self.num_variables
1042
- A = np.zeros((num_vars, num_vars))
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 not getattr(comp, "is_dissipative", False):
1054
- # Assign the row index for the cost balance equation to this component.
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 key, col in conn["CostVar_index"].items():
1061
- A[counter, col] = 1 # Incoming costs
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 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
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
- # 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)
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
- and (conn.get("target_component") in valid_component_names):
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
- 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}"
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
- A[counter, idx] = 1
1101
- b[counter] = conn.get("C_TOT", 0)
1102
- self.equations[counter] = f"boundary_stream_costs_{name}_TOT"
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
- A[counter, idx] = 1
1111
- b[counter] = conn.get("C_TOT", 0)
1112
- self.equations[counter] = f"boundary_stream_costs_{name}_TOT"
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 = [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)]
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
- 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']}"
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
- A, b, counter, self.equations = comp.aux_eqs(A, b, counter, Tamb, self.equations, self.chemical_exergy_enabled)
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
- 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
-
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
- exergy_cost_matrix, exergy_cost_vector = self.construct_matrix(Tamb)
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(exergy_cost_matrix, exergy_cost_vector)
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(f"Exergoeconomic system is singular and cannot be solved. "
1203
- f"Provided equations: {len(self.equations)}, variables in system: {len(self.variables)}")
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 3: Assign solutions to connections
1305
+ # Step 4: Assign solutions to connections
1206
1306
  for conn_name, conn in self.connections.items():
1207
- is_part_of_the_system = conn.get("source_component") or conn.get("target_component")
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) # 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]
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) # chemical specific exergy [kJ/kg]
1231
- E_CH = m_val * e_CH # chemical exergy flow [kW]
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 4: Assign C_P, C_F, C_D, and f values to components
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 5: Distribute the cost of loss streams to the product streams.
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 6: Compute system-level cost variables using the E_F and E_P dictionaries.
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: C_P ({self.system_costs['C_P']:.6f}) ≠ "
1329
- f"C_F ({self.system_costs['C_F']:.6f}) + Z ({self.system_costs['Z']:.6f})"
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
- return exergy_cost_matrix, exergy_cost_vector
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(f"Exergoeconomic analysis completed successfully.")
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 idx, row in df_comp.iterrows():
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[f"f [%]"] = f_list
1425
- df_comp[f"r [%]"] = r_list
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]"] = self.system_costs.get("Z", np.nan)
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]"] = 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
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 GJ/{currency})
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
- for idx, row in df_mat.iterrows():
1460
- conn_name = row['Connection']
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 [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
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 idx, row in df_non_mat.iterrows():
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 [GJ/{self.currency}]"] = c_TOT_non_mat
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
- "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()
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
- "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()
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='all', inplace=True)
1564
- df_mat2.dropna(axis=1, how='all', inplace=True)
1565
- df_non_mat.dropna(axis=1, how='all', inplace=True)
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['tau']
1622
- self.i_eff = pars['i_eff']
1623
- self.n = pars['n']
1624
- self.r_n = pars['r_n']
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
- Z_CC = [(levelized_investment_cost * pec / total_PEC) / self.tau for pec in PEC_list]
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
- Z_OM = [(levelized_om_cost * pec / total_PEC) / self.tau for pec in PEC_list]
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