Repository: WISDEM/ORBIT (language: python)

Issue:
Added initial CapEx breakdown to ProjectManager.
@gbarter Is this what you had in mind? This level of breakdown (by module) is the easiest to implement. See below for the API and how the results are structured.

![Screen Shot 2021-03-16 at 10 10 24 AM](https://user-images.githubusercontent.com/42254629/111344327-e6510e00-8641-11eb-8e67-0deb6fbd0db4.png)




Discussion / hints:

Fix patch:
diff --git a/ORBIT/manager.py b/ORBIT/manager.py
index 8f1f1c86..203c5fb1 100644
--- a/ORBIT/manager.py
+++ b/ORBIT/manager.py
@@ -4,6 +4,7 @@
 __email__ = ["jake.nunemaker@nrel.gov"]
 
 
+import os
 import re
 import datetime as dt
 import collections.abc as collections
@@ -163,6 +164,20 @@ def phases(self):
 
         return self._phases
 
+    @property
+    def _capex_categories(self):
+        """Returns CapEx categories for phases in `self._install_phases`."""
+
+        out = {}
+        for p in self._install_phases:
+            try:
+                out[p.__name__] = p.capex_category
+
+            except AttributeError:
+                out[p.__name__] = "Misc."
+
+        return out
+
     @property
     def project_params(self):
         """Returns defined project parameters, if found."""
@@ -790,6 +805,50 @@ def get_weather_profile(self, start):
 
         return self.weather.iloc[ceil(start) :].copy().to_records()
 
+    def outputs(self, include_logs=False, npv_detailed=False):
+        """Returns dict of all available outputs."""
+
+        out = {
+            # Times
+            "project_time": self.project_time,
+            "installation_time": self.installation_time,
+            # Costs
+            "capex_breakdown": self.capex_breakdown,
+            "capex_breakdown_per_kw": self.capex_breakdown_per_kw,
+            "turbine_capex": self.turbine_capex,
+            "turbine_capex_per_kw": self.turbine_capex_per_kw,
+            "installation_capex": self.installation_capex,
+            "installation_capex_per_kw": self.installation_capex_per_kw,
+            "system_capex": self.system_capex,
+            "system_capex_per_kw": self.system_capex_per_kw,
+            "overnight_capex": self.overnight_capex,
+            "overnight_capex_per_kw": self.overnight_capex_per_kw,
+            "soft_capex": self.soft_capex,
+            "soft_capex_per_kw": self.soft_capex_per_kw,
+            "bos_capex": self.bos_capex,
+            "bos_capex_per_kw": self.bos_capex_per_kw,
+            "project_capex": self.project_capex,
+            "project_capex_per_kw": self.project_capex_per_kw,
+            "total_capex": self.total_capex,
+            "total_capex_per_kw": self.total_capex_per_kw,
+            "npv": self.npv,
+        }
+
+        if include_logs:
+            out["logs"] = self.actions
+
+        if npv_detailed:
+            out = {
+                **out,
+                **{
+                    "cash_flow": self.cash_flow,
+                    "monthly_revenue": self.monthly_revenue,
+                    "monthly_expenses": self.monthly_expenses,
+                },
+            }
+
+        return out
+
     @property
     def capacity(self):
         """Returns project capacity in MW."""
@@ -1114,6 +1173,63 @@ def installation_capex_per_kw(self):
 
         return capex
 
+    @property
+    def capex_breakdown(self):
+        """Returns CapEx breakdown by category."""
+
+        unique = np.unique(
+            [*self.system_costs.keys(), *self.installation_costs.keys()]
+        )
+        categories = {}
+
+        for phase in unique:
+            for base, cat in self._capex_categories.items():
+                if base in phase:
+                    categories[phase] = cat
+                    break
+
+        missing = [p for p in unique if p not in categories.keys()]
+        if missing:
+            print(
+                f"Warning: CapEx category not found for {missing}. "
+                f"Added to 'Misc.'"
+            )
+
+            for phase in missing:
+                categories[phase] = "Misc."
+
+        outputs = {}
+        for phase, cost in self.system_costs.items():
+            name = categories[phase]
+            if name in outputs.keys():
+                outputs[name] += cost
+
+            else:
+                outputs[name] = cost
+
+        for phase, cost in self.installation_costs.items():
+            name = categories[phase] + " Installation"
+            if name in outputs.keys():
+                outputs[name] += cost
+
+            else:
+                outputs[name] = cost
+
+        outputs["Turbine"] = self.turbine_capex
+        outputs["Soft"] = self.soft_capex
+        outputs["Project"] = self.project_capex
+
+        return outputs
+
+    @property
+    def capex_breakdown_per_kw(self):
+        """Returns CapEx per kW breakdown by category."""
+
+        return {
+            k: v / (self.capacity * 1000)
+            for k, v in self.capex_breakdown.items()
+        }
+
     @property
     def bos_capex(self):
         """Returns total balance of system CapEx."""
@@ -1134,9 +1250,7 @@ def bos_capex_per_kw(self):
 
     @property
     def turbine_capex(self):
-        """
-        Returns the total turbine CAPEX.
-        """
+        """Returns the total turbine CAPEX."""
 
         _capex = self.project_params.get("turbine_capex", 1300)
         try:
@@ -1157,7 +1271,7 @@ def turbine_capex(self):
     def turbine_capex_per_kw(self):
         """Returns the turbine CapEx/kW."""
 
-        _capex = self.project_params.get("turbine_capex", None)
+        _capex = self.project_params.get("turbine_capex", 1300)
         return _capex
 
     @property
@@ -1263,6 +1377,38 @@ def export_configuration(self, file_name):
 
         export_library_specs("config", file_name, self.config)
 
+    def export_project_logs(self, filepath, level="ACTION"):
+        """
+        Exports the project logs to a .csv file.
+
+        Parameters
+        ----------
+        filepath : str
+            Filepath to save logs at.
+        level : str, optional
+            Log level to save.
+            Options: 'ACTION' | 'DEBUG'
+            Default: 'ACTION'
+        """
+
+        dirs = os.path.split(filepath)[0]
+        if dirs and not os.path.isdir(dirs):
+            os.makedirs(dirs)
+
+        if level == "ACTION":
+            out = pd.DataFrame(self.actions)
+
+        elif level == "DEBUG":
+            out = pd.DataFrame(self.logs)
+
+        else:
+            raise ValueError(
+                f"Unrecognized level '{level}'."
+                " Must be 'ACTION' or 'DEBUG'."
+            )
+
+        out.to_csv(filepath, index=False)
+
 
 class ProjectProgress:
     """Class to store, parse and return project progress data."""
diff --git a/ORBIT/phases/install/cable_install/array.py b/ORBIT/phases/install/cable_install/array.py
index ad822d0f..87ac9434 100644
--- a/ORBIT/phases/install/cable_install/array.py
+++ b/ORBIT/phases/install/cable_install/array.py
@@ -34,6 +34,7 @@ class ArrayCableInstallation(InstallPhase):
     """Array Cable Installation Phase"""
 
     phase = "Array Cable Installation"
+    capex_category = "Array System"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/cable_install/export.py b/ORBIT/phases/install/cable_install/export.py
index c97c10c2..04133a70 100644
--- a/ORBIT/phases/install/cable_install/export.py
+++ b/ORBIT/phases/install/cable_install/export.py
@@ -34,6 +34,7 @@ class ExportCableInstallation(InstallPhase):
     """Export Cable Installation Phase"""
 
     phase = "Export Cable Installation"
+    capex_category = "Export System"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/monopile_install/standard.py b/ORBIT/phases/install/monopile_install/standard.py
index 4e411eb1..27b53b8b 100644
--- a/ORBIT/phases/install/monopile_install/standard.py
+++ b/ORBIT/phases/install/monopile_install/standard.py
@@ -37,6 +37,7 @@ class MonopileInstallation(InstallPhase):
     """
 
     phase = "Monopile Installation"
+    capex_category = "Substructure"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/mooring_install/mooring.py b/ORBIT/phases/install/mooring_install/mooring.py
index de14d4b6..8cec663f 100644
--- a/ORBIT/phases/install/mooring_install/mooring.py
+++ b/ORBIT/phases/install/mooring_install/mooring.py
@@ -19,6 +19,7 @@ class MooringSystemInstallation(InstallPhase):
     """Module to model the installation of mooring systems at sea."""
 
     phase = "Mooring System Installation"
+    capex_category = "Mooring System"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/oss_install/standard.py b/ORBIT/phases/install/oss_install/standard.py
index 8f82b978..67612529 100644
--- a/ORBIT/phases/install/oss_install/standard.py
+++ b/ORBIT/phases/install/oss_install/standard.py
@@ -28,6 +28,7 @@ class OffshoreSubstationInstallation(InstallPhase):
     """
 
     phase = "Offshore Substation Installation"
+    capex_category = "Offshore Substation"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/quayside_assembly_tow/gravity_base.py b/ORBIT/phases/install/quayside_assembly_tow/gravity_base.py
index 4d9f2411..8a79d79f 100644
--- a/ORBIT/phases/install/quayside_assembly_tow/gravity_base.py
+++ b/ORBIT/phases/install/quayside_assembly_tow/gravity_base.py
@@ -22,6 +22,7 @@ class GravityBasedInstallation(InstallPhase):
     """
 
     phase = "Gravity Based Foundation Installation"
+    capex_category = "Substructure"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/quayside_assembly_tow/moored.py b/ORBIT/phases/install/quayside_assembly_tow/moored.py
index 1eb8dcc5..0ffbee18 100644
--- a/ORBIT/phases/install/quayside_assembly_tow/moored.py
+++ b/ORBIT/phases/install/quayside_assembly_tow/moored.py
@@ -22,6 +22,7 @@ class MooredSubInstallation(InstallPhase):
     """
 
     phase = "Moored Substructure Installation"
+    capex_category = "Substructure"
 
     #:
     expected_config = {
diff --git a/ORBIT/phases/install/scour_protection_install/standard.py b/ORBIT/phases/install/scour_protection_install/standard.py
index e75fe366..c5396790 100644
--- a/ORBIT/phases/install/scour_protection_install/standard.py
+++ b/ORBIT/phases/install/scour_protection_install/standard.py
@@ -41,6 +41,7 @@ class ScourProtectionInstallation(InstallPhase):
     }
 
     phase = "Scour Protection Installation"
+    capex_category = "Scour Protection"
 
     def __init__(self, config, weather=None, **kwargs):
         """
diff --git a/ORBIT/phases/install/turbine_install/standard.py b/ORBIT/phases/install/turbine_install/standard.py
index f00caf72..217fa483 100644
--- a/ORBIT/phases/install/turbine_install/standard.py
+++ b/ORBIT/phases/install/turbine_install/standard.py
@@ -43,6 +43,7 @@ class TurbineInstallation(InstallPhase):
     """
 
     phase = "Turbine Installation"
+    capex_category = "Turbine"
 
     #:
     expected_config = {
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index a0930fc4..9bd8ba6c 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -3,6 +3,11 @@
 ORBIT Changelog
 ===============
 
+1.0.2
+-----
+
+- Added ``ProjectManager.capex_breakdown``.
+
 1.0.1
 -----
 


Test patch:
diff --git a/tests/test_project_manager.py b/tests/test_project_manager.py
index 2ef95028..6b9438d9 100644
--- a/tests/test_project_manager.py
+++ b/tests/test_project_manager.py
@@ -693,3 +693,23 @@ def test_project_costs():
     config["project_parameters"] = {"installation_plan_cost": 25e6}
     project = ProjectManager(config)
     assert project.project_capex != baseline
+
+
+def test_capex_categories():
+
+    project = ProjectManager(complete_project)
+    project.run()
+    baseline = project.capex_breakdown
+    _ = project.capex_breakdown_per_kw
+
+    new_config = deepcopy(complete_project)
+    new_config["install_phases"]["ExportCableInstallation_1"] = 0
+    new_project = ProjectManager(new_config)
+    new_project.run()
+    new_breakdown = new_project.capex_breakdown
+
+    assert new_breakdown["Export System"] > baseline["Export System"]
+    assert (
+        new_breakdown["Export System Installation"]
+        > baseline["Export System Installation"]
+    )