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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
exerpy/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.0.3"
1
+ __version__ = "0.0.4"
2
2
 
3
3
  import importlib.resources
4
4
  import os
exerpy/analyses.py CHANGED
@@ -2,6 +2,7 @@ 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
@@ -71,6 +72,10 @@ class ExergyAnalysis:
71
72
  Creates an instance from a JSON file containing system data.
72
73
  exergy_results(print_results=True)
73
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.
74
79
  export_to_json(output_path)
75
80
  Exports the model and analysis results to a JSON file.
76
81
  _serialize()
@@ -193,6 +198,13 @@ class ExergyAnalysis:
193
198
  else:
194
199
  # Calculate E_F, E_D, E_P
195
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
+
196
208
  # Safely calculate y and y* avoiding division by zero
197
209
  if self.E_F != 0:
198
210
  component.y = component.E_D / self.E_F
@@ -563,6 +575,214 @@ class ExergyAnalysis:
563
575
 
564
576
  return df_component_results, df_material_connection_results, df_non_material_connection_results
565
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
+
566
786
  def export_to_json(self, output_path):
567
787
  """
568
788
  Export the model to a JSON file.
@@ -581,6 +801,7 @@ class ExergyAnalysis:
581
801
  -----
582
802
  This method serializes the model using the internal _serialize method
583
803
  and writes the resulting data to a JSON file with indentation.
804
+ NaN values are converted to null for valid JSON output.
584
805
  """
585
806
  data = self._serialize()
586
807
  with open(output_path, "w") as json_file:
@@ -610,21 +831,21 @@ class ExergyAnalysis:
610
831
  for comp_name, comp_data in comps.items():
611
832
  comp = self.components[comp_name]
612
833
  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),
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)),
619
840
  }
620
841
 
621
842
  # add overall system exergy results
622
843
  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),
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)),
628
849
  }
629
850
 
630
851
  return export
@@ -748,6 +969,13 @@ def _construct_components(component_data, connection_data, Tamb):
748
969
  return components # Return the dictionary of created components
749
970
 
750
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
+
751
979
  def _load_json(json_path):
752
980
  """
753
981
  Load and validate a JSON file.
@@ -1184,7 +1412,15 @@ class ExergoeconomicAnalysis:
1184
1412
  self.equations[counter] = {"kind": "boundary", "object": [name], "property": "c_TOT"}
1185
1413
  counter += 1
1186
1414
  else:
1187
- 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
1188
1424
 
1189
1425
  # 3. Auxiliary equations for the equality of the specific costs
1190
1426
  # of all power flows at the input or output of the system.
@@ -1320,26 +1556,26 @@ class ExergoeconomicAnalysis:
1320
1556
  E_M = m_val * e_M # mechanical exergy flow [kW]
1321
1557
 
1322
1558
  conn["C_T"] = C_solution[conn["CostVar_index"]["T"]]
1323
- 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
1324
1560
 
1325
1561
  conn["C_M"] = C_solution[conn["CostVar_index"]["M"]]
1326
- 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
1327
1563
 
1328
1564
  conn["C_PH"] = conn["C_T"] + conn["C_M"]
1329
- 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
1330
1566
 
1331
1567
  if self.chemical_exergy_enabled:
1332
1568
  e_CH = conn.get("e_CH", 0) # chemical specific exergy [kJ/kg]
1333
1569
  E_CH = m_val * e_CH # chemical exergy flow [kW]
1334
1570
  conn["C_CH"] = C_solution[conn["CostVar_index"]["CH"]]
1335
- 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
1336
1572
  conn["C_TOT"] = conn["C_T"] + conn["C_M"] + conn["C_CH"]
1337
1573
  total_E = E_T + E_M + E_CH
1338
- 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
1339
1575
  else:
1340
1576
  conn["C_TOT"] = conn["C_T"] + conn["C_M"]
1341
1577
  total_E = E_T + E_M
1342
- 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
1343
1579
  elif kind in {"heat", "power"}:
1344
1580
  conn["C_TOT"] = C_solution[conn["CostVar_index"]["exergy"]]
1345
1581
  conn["c_TOT"] = conn["C_TOT"] / conn.get("E", 1)
@@ -1465,7 +1701,7 @@ class ExergoeconomicAnalysis:
1465
1701
  dict
1466
1702
  Mapping from component name to tuple (balance, is_balanced),
1467
1703
  where balance is the residual and is_balanced is True if
1468
- |balance| <= tol.
1704
+ abs(balance) <= tol.
1469
1705
  """
1470
1706
  from .components.helpers.cycle_closer import CycleCloser
1471
1707
 
@@ -1476,10 +1712,20 @@ class ExergoeconomicAnalysis:
1476
1712
  inlet_sum = 0.0
1477
1713
  outlet_sum = 0.0
1478
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
1479
1725
  if conn.get("target_component") == name:
1480
- inlet_sum += conn.get("C_TOT", 0) or 0
1726
+ inlet_sum += cost
1481
1727
  if conn.get("source_component") == name:
1482
- outlet_sum += conn.get("C_TOT", 0) or 0
1728
+ outlet_sum += cost
1483
1729
  comp.C_in = inlet_sum
1484
1730
  comp.C_out = outlet_sum
1485
1731
  z_cost = getattr(comp, "Z_costs", 0)
@@ -1489,11 +1735,11 @@ class ExergoeconomicAnalysis:
1489
1735
 
1490
1736
  all_ok = all(flag for _, flag in balances.values())
1491
1737
  if all_ok:
1492
- print("Everything is fine: all component cost balances are fulfilled.")
1738
+ logging.info("All component cost balances are fulfilled.")
1493
1739
  else:
1494
1740
  for name, (bal, ok) in balances.items():
1495
1741
  if not ok:
1496
- print(f"Balance for component {name} not fulfilled: residual = {bal:.6f}")
1742
+ logging.warning(f"Balance for component {name} not fulfilled: residual = {bal:.6f}")
1497
1743
 
1498
1744
  return balances
1499
1745
 
@@ -1522,7 +1768,6 @@ class ExergoeconomicAnalysis:
1522
1768
  self.solve_exergoeconomic_analysis(Tamb)
1523
1769
  logging.info("Exergoeconomic analysis completed successfully.")
1524
1770
  self.check_cost_balance()
1525
- print("stop")
1526
1771
 
1527
1772
  def print_equations(self):
1528
1773
  """
@@ -498,8 +498,8 @@ class HeatExchanger(Component):
498
498
  # Equality equation for mechanical and chemical exergy costs.
499
499
  def set_equal(A, row, in_item, out_item, var):
500
500
  if in_item["e_" + var] != 0 and out_item["e_" + var] != 0:
501
- A[row, in_item["CostVar_index"][var]] = 1 / in_item["e_" + var]
502
- A[row, out_item["CostVar_index"][var]] = -1 / out_item["e_" + var]
501
+ A[row, in_item["CostVar_index"][var]] = 1 / in_item["E_" + var]
502
+ A[row, out_item["CostVar_index"][var]] = -1 / out_item["E_" + var]
503
503
  elif in_item["e_" + var] == 0 and out_item["e_" + var] != 0:
504
504
  A[row, in_item["CostVar_index"][var]] = 1
505
505
  elif in_item["e_" + var] != 0 and out_item["e_" + var] == 0:
@@ -221,8 +221,8 @@ class Condenser(Component):
221
221
  # Equality equation for mechanical and chemical exergy costs.
222
222
  def set_equal(A, row, in_item, out_item, var):
223
223
  if in_item["e_" + var] != 0 and out_item["e_" + var] != 0:
224
- A[row, in_item["CostVar_index"][var]] = 1 / in_item["e_" + var]
225
- A[row, out_item["CostVar_index"][var]] = -1 / out_item["e_" + var]
224
+ A[row, in_item["CostVar_index"][var]] = 1 / in_item["E_" + var]
225
+ A[row, out_item["CostVar_index"][var]] = -1 / out_item["E_" + var]
226
226
  elif in_item["e_" + var] == 0 and out_item["e_" + var] != 0:
227
227
  A[row, in_item["CostVar_index"][var]] = 1
228
228
  elif in_item["e_" + var] != 0 and out_item["e_" + var] == 0:
@@ -269,17 +269,45 @@ class Compressor(Component):
269
269
  else:
270
270
  logging.warning("Case where thermal or mechanical exergy difference is zero is not implemented.")
271
271
  elif self.inl[0]["T"] <= T0 and self.outl[0]["T"] > T0:
272
- A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
273
- A[row_index, self.inl[0]["CostVar_index"]["M"]] = 1 / dEM
274
- A[row_index, self.outl[0]["CostVar_index"]["M"]] = -1 / dEM
275
- equations[row_index] = {
276
- "kind": "aux_p_rule",
277
- "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
278
- "property": "c_T, c_M",
279
- }
272
+ # Case 2: Inlet at/below ambient, outlet above ambient
273
+ # Handle potential zero values for robustness
274
+ if self.outl[0]["e_T"] != 0 and dEM != 0:
275
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
276
+ A[row_index, self.inl[0]["CostVar_index"]["M"]] = 1 / dEM
277
+ A[row_index, self.outl[0]["CostVar_index"]["M"]] = -1 / dEM
278
+ equations[row_index] = {
279
+ "kind": "aux_p_rule",
280
+ "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
281
+ "property": "c_T, c_M",
282
+ }
283
+ else:
284
+ logging.warning(
285
+ f"Compressor '{self.name}' Case 2: outlet thermal exergy or mechanical exergy "
286
+ "difference is zero, auxiliary equation may be degenerate."
287
+ )
288
+ # Fallback: set identity equation for thermal cost
289
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1
290
+ equations[row_index] = {
291
+ "kind": "aux_p_rule",
292
+ "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
293
+ "property": "c_T",
294
+ }
280
295
  else:
281
- A[row_index, self.inl[0]["CostVar_index"]["T"]] = -1 / self.inl[0]["E_T"]
282
- A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
296
+ # Case 3: Both temperatures at or below ambient - apply F-rule for thermal exergy
297
+ # Handle zero thermal exergy cases to avoid division by zero
298
+ if self.inl[0]["e_T"] != 0 and self.outl[0]["e_T"] != 0:
299
+ A[row_index, self.inl[0]["CostVar_index"]["T"]] = -1 / self.inl[0]["E_T"]
300
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
301
+ elif self.inl[0]["e_T"] == 0 and self.outl[0]["e_T"] != 0:
302
+ # Inlet thermal exergy is zero, constrain C_T_in = 0
303
+ A[row_index, self.inl[0]["CostVar_index"]["T"]] = 1
304
+ elif self.inl[0]["e_T"] != 0 and self.outl[0]["e_T"] == 0:
305
+ # Outlet thermal exergy is zero, constrain C_T_out = 0
306
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1
307
+ else:
308
+ # Both thermal exergies are zero, set identity equation
309
+ A[row_index, self.inl[0]["CostVar_index"]["T"]] = 1
310
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = -1
283
311
  equations[row_index] = {
284
312
  "kind": "aux_f_rule",
285
313
  "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
@@ -262,17 +262,45 @@ class Pump(Component):
262
262
  else:
263
263
  logging.warning("Case where thermal or mechanical exergy difference is zero is not implemented.")
264
264
  elif self.inl[0]["T"] <= T0 and self.outl[0]["T"] > T0:
265
- A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
266
- A[row_index, self.inl[0]["CostVar_index"]["M"]] = 1 / dEM
267
- A[row_index, self.outl[0]["CostVar_index"]["M"]] = -1 / dEM
268
- equations[row_index] = {
269
- "kind": "aux_p_rule",
270
- "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
271
- "property": "c_T, c_M",
272
- }
265
+ # Case 2: Inlet at/below ambient, outlet above ambient
266
+ # Handle potential zero values for robustness
267
+ if self.outl[0]["e_T"] != 0 and dEM != 0:
268
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
269
+ A[row_index, self.inl[0]["CostVar_index"]["M"]] = 1 / dEM
270
+ A[row_index, self.outl[0]["CostVar_index"]["M"]] = -1 / dEM
271
+ equations[row_index] = {
272
+ "kind": "aux_p_rule",
273
+ "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
274
+ "property": "c_T, c_M",
275
+ }
276
+ else:
277
+ logging.warning(
278
+ f"Pump '{self.name}' Case 2: outlet thermal exergy or mechanical exergy "
279
+ "difference is zero, auxiliary equation may be degenerate."
280
+ )
281
+ # Fallback: set identity equation for thermal cost
282
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1
283
+ equations[row_index] = {
284
+ "kind": "aux_p_rule",
285
+ "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
286
+ "property": "c_T",
287
+ }
273
288
  else:
274
- A[row_index, self.inl[0]["CostVar_index"]["T"]] = -1 / self.inl[0]["E_T"]
275
- A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
289
+ # Case 3: Both temperatures at or below ambient - apply F-rule for thermal exergy
290
+ # Handle zero thermal exergy cases to avoid division by zero
291
+ if self.inl[0]["e_T"] != 0 and self.outl[0]["e_T"] != 0:
292
+ A[row_index, self.inl[0]["CostVar_index"]["T"]] = -1 / self.inl[0]["E_T"]
293
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1 / self.outl[0]["E_T"]
294
+ elif self.inl[0]["e_T"] == 0 and self.outl[0]["e_T"] != 0:
295
+ # Inlet thermal exergy is zero, constrain C_T_in = 0
296
+ A[row_index, self.inl[0]["CostVar_index"]["T"]] = 1
297
+ elif self.inl[0]["e_T"] != 0 and self.outl[0]["e_T"] == 0:
298
+ # Outlet thermal exergy is zero, constrain C_T_out = 0
299
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = 1
300
+ else:
301
+ # Both thermal exergies are zero, set identity equation
302
+ A[row_index, self.inl[0]["CostVar_index"]["T"]] = 1
303
+ A[row_index, self.outl[0]["CostVar_index"]["T"]] = -1
276
304
  equations[row_index] = {
277
305
  "kind": "aux_f_rule",
278
306
  "objects": [self.name, self.inl[0]["name"], self.outl[0]["name"]],
@@ -0,0 +1,65 @@
1
+ """
2
+ Cost estimation module for ExerPy.
3
+
4
+ This module provides cost estimation functionality for exergoeconomic analysis.
5
+ Different cost estimation methods can be implemented as separate classes.
6
+
7
+ Available Estimators
8
+ --------------------
9
+ TurtonCostEstimator : Turton 2008 correlations (requires proprietary data files)
10
+ Uses correlations from "Analysis, Synthesis, and Design of Chemical Processes"
11
+ by Turton et al. (2008) with CEPCI cost index adjustment.
12
+
13
+ Notes
14
+ -----
15
+ The TurtonCostEstimator requires proprietary data files that are not distributed
16
+ with ExerPy due to copyright restrictions. Users can:
17
+
18
+ 1. Provide their own cost data files in the expected format
19
+ 2. Provide costs directly to ExergoeconomicAnalysis.run() without using an estimator
20
+ 3. Implement their own cost estimator class
21
+
22
+ Example of providing costs directly::
23
+
24
+ execo = ExergoeconomicAnalysis(exergy_analysis)
25
+ costs = {
26
+ "pump_1_Z": 5.0, # EUR/h
27
+ "hx_1_Z": 12.0, # EUR/h
28
+ "fuel_in_c": 3.5, # EUR/GJ for input stream
29
+ }
30
+ execo.run(costs, Tamb=298.15)
31
+ """
32
+
33
+ try:
34
+ from .turton import TurtonCostEstimator
35
+
36
+ __all__ = ["TurtonCostEstimator"]
37
+ except ImportError:
38
+ __all__ = []
39
+
40
+ class TurtonCostEstimator:
41
+ """
42
+ Placeholder for TurtonCostEstimator when data files are not available.
43
+
44
+ The actual TurtonCostEstimator requires proprietary cost correlation data
45
+ files from Turton et al. (2008) which cannot be distributed with ExerPy.
46
+
47
+ To use cost estimation with Turton correlations, you need to:
48
+ 1. Obtain the correlation data from the original source
49
+ 2. Create the required JSON files in src/exerpy/data/cost_correlations/
50
+ 3. Ensure turton.py is present in src/exerpy/cost_estimation/
51
+
52
+ Alternatively, provide component costs directly to ExergoeconomicAnalysis.run().
53
+ """
54
+
55
+ def __init__(self, *args, **kwargs):
56
+ raise ImportError(
57
+ "TurtonCostEstimator is not available. "
58
+ "The required cost correlation data files (Turton 2008, CEPCI) "
59
+ "are not included in this distribution due to copyright restrictions.\n\n"
60
+ "Options:\n"
61
+ "1. Provide your own cost data files in src/exerpy/data/cost_correlations/\n"
62
+ "2. Provide costs directly to ExergoeconomicAnalysis.run() method\n"
63
+ "3. Implement your own cost estimator\n\n"
64
+ "See ExergoeconomicAnalysis documentation for the required cost format."
65
+ )