OpenPyTEA 3.1.0__tar.gz → 3.2.0__tar.gz

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 (29) hide show
  1. {openpytea-3.1.0/src/OpenPyTEA.egg-info → openpytea-3.2.0}/PKG-INFO +1 -1
  2. {openpytea-3.1.0 → openpytea-3.2.0}/pyproject.toml +1 -1
  3. {openpytea-3.1.0 → openpytea-3.2.0/src/OpenPyTEA.egg-info}/PKG-INFO +1 -1
  4. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/io.py +108 -37
  5. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_composite_equipment.py +30 -0
  6. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_run_tea.py +47 -0
  7. {openpytea-3.1.0 → openpytea-3.2.0}/LICENSE +0 -0
  8. {openpytea-3.1.0 → openpytea-3.2.0}/README.md +0 -0
  9. {openpytea-3.1.0 → openpytea-3.2.0}/setup.cfg +0 -0
  10. {openpytea-3.1.0 → openpytea-3.2.0}/src/OpenPyTEA.egg-info/SOURCES.txt +0 -0
  11. {openpytea-3.1.0 → openpytea-3.2.0}/src/OpenPyTEA.egg-info/dependency_links.txt +0 -0
  12. {openpytea-3.1.0 → openpytea-3.2.0}/src/OpenPyTEA.egg-info/entry_points.txt +0 -0
  13. {openpytea-3.1.0 → openpytea-3.2.0}/src/OpenPyTEA.egg-info/requires.txt +0 -0
  14. {openpytea-3.1.0 → openpytea-3.2.0}/src/OpenPyTEA.egg-info/top_level.txt +0 -0
  15. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/__init__.py +0 -0
  16. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/analysis.py +0 -0
  17. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/cli.py +0 -0
  18. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/data/cepci_values.csv +0 -0
  19. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/data/cost_correlations.csv +0 -0
  20. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/equipment.py +0 -0
  21. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/helpers.py +0 -0
  22. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/plant.py +0 -0
  23. {openpytea-3.1.0 → openpytea-3.2.0}/src/openpytea/plotting.py +0 -0
  24. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_analysis.py +0 -0
  25. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_cli.py +0 -0
  26. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_equipment.py +0 -0
  27. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_plant.py +0 -0
  28. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_plotting.py +0 -0
  29. {openpytea-3.1.0 → openpytea-3.2.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenPyTEA
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: OpenPyTEA: An open-source Python toolkit for techno-economic assessment of process plants with economic sensitivity and uncertainty evaluation
5
5
  Maintainer-email: "Panji B. Tamarona" <P.B.Tamarona@tudelft.nl>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "OpenPyTEA"
3
- version = "3.1.0"
3
+ version = "3.2.0"
4
4
  description = "OpenPyTEA: An open-source Python toolkit for techno-economic assessment of process plants with economic sensitivity and uncertainty evaluation"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenPyTEA
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: OpenPyTEA: An open-source Python toolkit for techno-economic assessment of process plants with economic sensitivity and uncertainty evaluation
5
5
  Maintainer-email: "Panji B. Tamarona" <P.B.Tamarona@tudelft.nl>
6
6
  License-Expression: MIT
@@ -5,7 +5,7 @@ from importlib.metadata import version
5
5
 
6
6
  import matplotlib.pyplot as plt
7
7
 
8
- from openpytea.equipment import Equipment
8
+ from openpytea.equipment import Equipment, CompositeEquipment
9
9
  from openpytea.plant import Plant
10
10
  from openpytea.analysis import (
11
11
  direct_costs_data,
@@ -76,6 +76,11 @@ def load_equipment_config(filepath):
76
76
  material (falls back to 'Carbon steel'), target_year=2024
77
77
  - Optional keys: 'type', 'material', 'num_units', 'purchased_cost',
78
78
  'cost_year', 'cost_func', 'target_year'
79
+ - An entry with a 'components' list (of equipment entries, nesting
80
+ allowed) builds a CompositeEquipment; it requires 'name' and
81
+ 'process_type' and accepts 'category' (default 'Composite'),
82
+ 'type', 'installation', 'purchased_cost', 'cost_year',
83
+ 'target_year' and 'num_units' (default 1)
79
84
  """
80
85
  data = _read_json(filepath)
81
86
  return _build_equipment_list(data)
@@ -99,45 +104,87 @@ def _build_equipment_list(data):
99
104
  "'equipment' must be a list of equipment entries."
100
105
  )
101
106
 
102
- equipment_list = []
107
+ return [
108
+ _build_equipment(entry, f"#{i}")
109
+ for i, entry in enumerate(data["equipment"], start=1)
110
+ ]
103
111
 
104
- for i, entry in enumerate(data["equipment"], start=1):
105
- if not isinstance(entry, dict):
106
- raise ValueError(
107
- f"Equipment entry #{i} must be a dictionary."
108
- )
109
112
 
110
- required = ["name", "process_type", "category"]
111
- missing = [k for k in required if k not in entry]
113
+ # Installation-factor overrides accepted by Equipment and CompositeEquipment
114
+ _FACTOR_KEYS = (
115
+ "erection_factor", "piping_factor", "instrumentation_factor",
116
+ "electrical_factor", "civil_factor", "structural_factor",
117
+ "lagging_factor", "material_factor",
118
+ )
119
+
120
+
121
+ def _build_equipment(entry, label, target_year=2024):
122
+ """
123
+ Build one Equipment, or a CompositeEquipment when the entry has a
124
+ 'components' list (built recursively; components without their own
125
+ 'target_year' inherit the composite's).
126
+ """
127
+ if not isinstance(entry, dict):
128
+ raise ValueError(f"Equipment entry {label} must be a dictionary.")
129
+
130
+ target_year = entry.get("target_year", target_year)
131
+ factors = {k: entry[k] for k in _FACTOR_KEYS if k in entry}
132
+
133
+ if "components" in entry:
134
+ missing = [k for k in ("name", "process_type") if k not in entry]
112
135
  if missing:
113
136
  raise ValueError(
114
- f"Equipment entry #{i} is missing required keys: {missing}"
137
+ f"Equipment entry {label} is missing required keys: {missing}"
115
138
  )
116
-
117
- if "purchased_cost" not in entry and "param" not in entry:
139
+ if not isinstance(entry["components"], list):
118
140
  raise ValueError(
119
- f"Equipment entry #{i} must define either 'param' "
120
- f"or 'purchased_cost'."
141
+ f"'components' of equipment entry {label} must be a list."
121
142
  )
122
-
123
- # ponytail: copies keys by hand; Equipment(**{'param': 0.0, **entry})
124
- eq = Equipment(
143
+ return CompositeEquipment(
125
144
  name=entry["name"],
126
- param=entry.get("param", 0.0),
127
145
  process_type=entry["process_type"],
128
- category=entry["category"],
146
+ components=[
147
+ _build_equipment(sub, f"{label}.{j}", target_year)
148
+ for j, sub in enumerate(entry["components"], start=1)
149
+ ],
150
+ category=entry.get("category", "Composite"),
129
151
  type=entry.get("type"),
130
- material=entry.get("material"),
131
- num_units=entry.get("num_units"),
152
+ installation=entry.get("installation", "component"),
132
153
  purchased_cost=entry.get("purchased_cost"),
133
154
  cost_year=entry.get("cost_year"),
134
- cost_func=entry.get("cost_func"),
135
- target_year=entry.get("target_year", 2024),
155
+ target_year=target_year,
156
+ num_units=entry.get("num_units", 1),
157
+ **factors,
136
158
  )
137
159
 
138
- equipment_list.append(eq)
160
+ required = ["name", "process_type", "category"]
161
+ missing = [k for k in required if k not in entry]
162
+ if missing:
163
+ raise ValueError(
164
+ f"Equipment entry {label} is missing required keys: {missing}"
165
+ )
139
166
 
140
- return equipment_list
167
+ if "purchased_cost" not in entry and "param" not in entry:
168
+ raise ValueError(
169
+ f"Equipment entry {label} must define either 'param' "
170
+ f"or 'purchased_cost'."
171
+ )
172
+
173
+ # ponytail: copies keys by hand; Equipment(**{'param': 0.0, **entry})
174
+ return Equipment(
175
+ name=entry["name"],
176
+ param=entry.get("param", 0.0),
177
+ process_type=entry["process_type"],
178
+ category=entry["category"],
179
+ type=entry.get("type"),
180
+ material=entry.get("material"),
181
+ num_units=entry.get("num_units"),
182
+ purchased_cost=entry.get("purchased_cost"),
183
+ cost_year=entry.get("cost_year"),
184
+ cost_func=entry.get("cost_func"),
185
+ target_year=target_year,
186
+ **factors,
187
+ )
141
188
 
142
189
 
143
190
  def load_plant_config(filepath, equipment_list):
@@ -555,6 +602,10 @@ def run_openpytea(config_path, output_dir=None):
555
602
  return _run_analyses(equipment_list, plant, config, output_dir)
556
603
 
557
604
 
605
+ # Analyses whose function takes an ``additional_capex`` argument
606
+ _ADDITIONAL_CAPEX_ANALYSES = {"fixed_capital", "tornado", "monte_carlo"}
607
+
608
+
558
609
  def _run_analyses(equipment_list, plant, analysis_cfg, output_dir):
559
610
  """
560
611
  Run the configured analyses for a loaded plant/equipment pair and
@@ -602,7 +653,10 @@ def _run_analyses(equipment_list, plant, analysis_cfg, output_dir):
602
653
  if save_json or save_plots:
603
654
  output_dir.mkdir(parents=True, exist_ok=True)
604
655
 
605
- plant.calculate_all()
656
+ # One switch so the exported plant results and the analyses agree on
657
+ # whether additional capex counts; an analysis' own args still win
658
+ additional_capex = analysis_block.get("additional_capex", False)
659
+ plant.calculate_all(additional_capex=additional_capex)
606
660
 
607
661
  results = {}
608
662
 
@@ -621,15 +675,20 @@ def _run_analyses(equipment_list, plant, analysis_cfg, output_dir):
621
675
  cfg = analysis_block.get(key, {})
622
676
  if cfg.get("run"):
623
677
  args = cfg.get("args", {})
678
+ if key in _ADDITIONAL_CAPEX_ANALYSES:
679
+ args = {"additional_capex": additional_capex, **args}
624
680
  results[key] = func(plant, **args)
625
681
 
626
682
  sens_cfg = analysis_block.get("sensitivity", {})
683
+ sens_plot_args = {}
627
684
  if sens_cfg.get("run", False):
628
685
  results["sensitivity"] = {}
629
686
  for i, case in enumerate(sens_cfg.get("cases", []), start=1):
630
687
  name = case.get("name", f"case_{i}")
631
- args = case.get("args", {})
688
+ args = {"additional_capex": additional_capex,
689
+ **case.get("args", {})}
632
690
  results["sensitivity"][name] = sensitivity_data(plant, **args)
691
+ sens_plot_args[name] = case.get("plot_args", {})
633
692
 
634
693
  if save_json:
635
694
  export_equipment_results(
@@ -667,13 +726,18 @@ def _run_analyses(equipment_list, plant, analysis_cfg, output_dir):
667
726
  ("cash_flow", plot_cash_flow),
668
727
  ):
669
728
  if key in results:
670
- _save(plot_fn(results[key], show=False)[0], key)
729
+ plot_args = analysis_block[key].get("plot_args", {})
730
+ _save(plot_fn(results[key], show=False, **plot_args)[0], key)
671
731
 
672
732
  for name, data in results.get("sensitivity", {}).items():
673
- _save(plot_sensitivity(data, show=False)[0], f"sensitivity_{name}")
733
+ plot_args = sens_plot_args.get(name, {})
734
+ _save(plot_sensitivity(data, show=False, **plot_args)[0],
735
+ f"sensitivity_{name}")
674
736
 
675
737
  if "tornado" in results:
676
- _save(plot_tornado(results["tornado"], show=False)[0], "tornado")
738
+ plot_args = analysis_block["tornado"].get("plot_args", {})
739
+ _save(plot_tornado(results["tornado"], show=False, **plot_args)[0],
740
+ "tornado")
677
741
 
678
742
  if "monte_carlo" in results:
679
743
  mc_results = results["monte_carlo"]
@@ -704,17 +768,24 @@ def _run_analyses(equipment_list, plant, analysis_cfg, output_dir):
704
768
  mc_results,
705
769
  metric=metric_name,
706
770
  show=False,
771
+ **mc_cfg.get("plot_args", {}),
707
772
  )
708
773
  _save(fig, f"monte_carlo_{metric_name.lower()}")
709
774
 
710
- if mc_cfg.get("plot_inputs", False):
711
- fig_process, _, fig_economic, _ = plot_monte_carlo_inputs(
712
- mc_results, show=False
775
+ inputs_cfg = mc_cfg.get("plot_inputs", False)
776
+ if inputs_cfg:
777
+ # true, or a dict of plot_monte_carlo_inputs kwargs
778
+ plot_args = inputs_cfg if isinstance(inputs_cfg, dict) else {}
779
+ out = plot_monte_carlo_inputs(
780
+ mc_results, show=False, **plot_args
781
+ )
782
+ category = plot_args.get("category", "both")
783
+ groups = (
784
+ ("process", "economic") if category == "both"
785
+ else (category,)
713
786
  )
714
- for group_name, fig in (
715
- ("process", fig_process),
716
- ("economic", fig_economic),
717
- ):
787
+ # out is (fig, axes) per group, flattened
788
+ for group_name, fig in zip(groups, out[::2]):
718
789
  if fig is None:
719
790
  continue # no inputs sampled for this group
720
791
  _save(fig, f"monte_carlo_inputs_{group_name}")
@@ -218,3 +218,33 @@ def test_composite_in_plant_and_direct_costs_data(psa):
218
218
  expanded = direct_costs_data(plant, expand_composites=True)["components"][0]
219
219
  assert set(expanded) == {"PSA / Vessel", "PSA / Adsorbent", "Pump"}
220
220
  assert sum(expanded.values()) == pytest.approx(sum(lumped.values()))
221
+
222
+
223
+ def test_json_builds_nested_composite(psa):
224
+ from openpytea.io import _build_equipment_list
225
+
226
+ def part(name, cost, process_type, category, **kw):
227
+ return {"name": name, "purchased_cost": cost, "cost_year": 2024,
228
+ "process_type": process_type, "category": category, **kw}
229
+
230
+ entry = {
231
+ "name": "Train", "process_type": "Fluids", "num_units": 2,
232
+ "components": [
233
+ {"name": "PSA", "process_type": "Fluids",
234
+ "category": "Adsorption", "type": "PSA",
235
+ "components": [
236
+ part("Vessel", 10_000, "Fluids", "Pressure vessels",
237
+ num_units=4),
238
+ part("Adsorbent", 5_000, "Solids",
239
+ "Packings & adsorbents"),
240
+ ]},
241
+ ],
242
+ }
243
+ (train,) = _build_equipment_list({"equipment": [entry]})
244
+
245
+ assert isinstance(train, CompositeEquipment)
246
+ assert train.category == "Composite"
247
+ built_psa = train.components[0]
248
+ assert isinstance(built_psa, CompositeEquipment)
249
+ assert built_psa.direct_cost == pytest.approx(psa.direct_cost)
250
+ assert train.direct_cost == pytest.approx(2 * psa.direct_cost)
@@ -1,4 +1,6 @@
1
1
  import json
2
+
3
+ import pytest
2
4
  from openpytea import run_tea, run_openpytea
3
5
 
4
6
 
@@ -286,3 +288,48 @@ def test_run_tea_python_api_honors_config_directory(tmp_path):
286
288
  )
287
289
 
288
290
  assert (config_dir / "Test Plant_analysis_results.json").exists()
291
+
292
+
293
+ def test_run_openpytea_passes_factors_capex_and_plot_args(tmp_path):
294
+ equipment = _equipment_data()
295
+ equipment["equipment"][1]["piping_factor"] = 0.0
296
+ plant = _plant_data()
297
+ plant["plant"]["additional_capex_years"] = [5]
298
+ plant["plant"]["additional_capex_cost"] = [50_000]
299
+ analysis = {
300
+ "analysis": {
301
+ "additional_capex": True,
302
+ "fixed_capital": {"run": True, "plot_args": {"figsize": [2, 2]}},
303
+ "monte_carlo": {
304
+ "run": True,
305
+ "args": {"num_samples": 100, "batch_size": 20},
306
+ "metric": ["LCOP"],
307
+ "plot_args": {"bins": 5},
308
+ "plot_inputs": {"category": "economic"},
309
+ },
310
+ },
311
+ "output": {"save_json": True, "save_plots": True},
312
+ }
313
+ config_path = tmp_path / "config.json"
314
+ config_path.write_text(
315
+ json.dumps({**equipment, **plant, **analysis}), encoding="utf-8"
316
+ )
317
+ out = tmp_path / "results"
318
+ run_openpytea(config_path=config_path, output_dir=out)
319
+
320
+ eq = json.loads((out / "Test Plant_equipment_results.json").read_text())
321
+ pump = eq["equipment"][1]
322
+ reactor = eq["equipment"][0]
323
+ # Same purchased cost and process type, only the pump drops piping
324
+ assert pump["direct_cost"] / pump["purchased_cost"] < (
325
+ reactor["direct_cost"] / reactor["purchased_cost"]
326
+ )
327
+
328
+ from openpytea.io import _build_equipment_list, _build_plant
329
+ expected = _build_plant(plant, _build_equipment_list(equipment))
330
+ expected.calculate_all(additional_capex=True)
331
+ plant_res = json.loads((out / "Test Plant_plant_results.json").read_text())
332
+ assert plant_res["metrics"]["roi"] == pytest.approx(expected.roi)
333
+
334
+ assert (out / "Test Plant_monte_carlo_inputs_economic.png").exists()
335
+ assert not (out / "Test Plant_monte_carlo_inputs_process.png").exists()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes