diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f315b7db..7c5f075f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: black name: black - stages: [commit] + stages: [pre-commit] exclude: ^ORBIT/api/wisdem - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/ORBIT/manager.py b/ORBIT/manager.py index 84232f63..dcc31a70 100644 --- a/ORBIT/manager.py +++ b/ORBIT/manager.py @@ -323,11 +323,21 @@ def compile_input_dict(cls, phases): "project_lifetime": "yrs (optional, default: 25)", "discount_rate": "yearly (optional, default: .025)", "opex_rate": "$/kW/year (optional, default: 150)", - "construction_insurance": "$/kW (optional, default: 44)", - "construction_financing": "$/kW (optional, default: 183)", - "contingency": "$/kW (optional, default: 316)", - "commissioning": "$/kW (optional, default: 44)", - "decommissioning": "$/kW (optional, default: 58)", + "construction_insurance": "$/kW (optional, default: value calculated using construction_insurance_factor)", + "construction_financing": "$/kW (optional, default: value calculated using construction_financing_factor))", + "procurement_contingency": "$/kW (optional, default: value calculated using procurement_contingency_factor)", + "installation_contingency": "$/kW (optional, default: value calculated using installation_contingency_factor)", + "decommissioning": "$/kW (optional, default: value calculated using decommissioning_factor)", + "project_completion": "$/kW (optional, default: value calculated using project_completion_factor)", + "construction_insurance_factor": "float (optional, default: 0.0115)", + "construction_financing_factor": "$/kW (optional, default: value calculated using spend_schedule, tax_rate and interest_during_construction))", + "spend_schedule": "dict (optional, default: {0: 0.25, 1: 0.25, 2: 0.3, 3: 0.1, 4: 0.1, 5: 0.0}", + "tax_rate": "float (optional, default: 0.26", + "interest_during_construction": "float (optional, default: 0.044", + "procurement_contingency_factor": "float (optional, default: 0.0575)", + "installation_contingency_factor": "float (optional, default: 0.345)", + "decommissioning_factor": "float (optional, default: 0.1725)", + "project_completion_factor": "float (optional, default: 0.0115)", "site_auction_price": "$ (optional, default: 100e6)", "site_assessment_cost": "$ (optional, default: 50e6)", "construction_plan_cost": "$ (optional, default: 1e6)", @@ -1372,7 +1382,9 @@ def capex_breakdown(self): outputs[name] = cost outputs["Turbine"] = self.turbine_capex + outputs["Soft"] = self.soft_capex + outputs["Project"] = self.project_capex return outputs @@ -1386,6 +1398,65 @@ def capex_breakdown_per_kw(self): for k, v in self.capex_breakdown.items() } + @property + def capex_detailed_soft_capex_breakdown(self): + """Returns CapEx breakdown by category with a detailed soft capex breakdown.""" + + 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 = list(set(unique).difference([*categories])) + 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: + outputs[name] += cost + + else: + outputs[name] = cost + + for phase, cost in self.installation_costs.items(): + name = categories[phase] + " Installation" + if name in outputs: + outputs[name] += cost + + else: + outputs[name] = cost + + outputs["Turbine"] = self.turbine_capex + + outputs = {**outputs, **self.soft_capex_breakdown} + + outputs["Project"] = self.project_capex + + return outputs + + @property + def capex_detailed_soft_capex_breakdown_per_kw(self): + """Returns CapEx per kW breakdown by category with a detailed soft capex breakdown.""" + + return { + k: v / (self.capacity * 1000) + for k, v in self.capex_detailed_soft_capex_breakdown.items() + } + @property def bos_capex(self): """Returns total balance of system CapEx.""" @@ -1438,10 +1509,16 @@ def overnight_capex(self): @property def soft_capex(self): - """Returns total project cost costs.""" + """Returns Total Soft CapEx.""" + + return sum(self.soft_capex_breakdown.values()) + + @property + def soft_capex_per_kw(self): + """Returns Total Soft CapEx per kW.""" try: - capex = self.soft_capex_per_kw * self.capacity * 1000 + capex = sum(self.soft_capex_breakdown.values()) / (self.capacity * 1000) except TypeError: capex = None @@ -1449,27 +1526,189 @@ def soft_capex(self): return capex @property - def soft_capex_per_kw(self): + def soft_capex_breakdown(self): + """Returns soft cost breakdown.""" + + soft_capex = {"Construction Insurance": self.construction_insurance_capex, + "Decommissioning": self.decommissioning_capex, + "Project Completion": self.project_completion_capex, + "Procurement Contingency": self.procurement_contingency_capex, + "Installation Contingency": self.installation_contingency_capex, + "Construction Financing": self.construction_financing_capex + } + + return soft_capex + + + + @property + def construction_insurance_capex(self): """ - Returns project soft costs per kW. Default numbers are based on the - Cost of Energy Review (Stehly and Beiter 2018). + Returns the construction insurance capital cost of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review. """ - insurance = self.project_params.get("construction_insurance", 44) - financing = self.project_params.get("construction_financing", 183) - contingency = self.project_params.get("contingency", 316) - commissioning = self.project_params.get("commissioning", 44) - decommissioning = self.project_params.get("decommissioning", 58) + try: + construction_insurance_per_kW = self.config["project_parameters"]["construction_insurance"] + num_turbines = self.config["plant"]["num_turbines"] + rating = self.config["turbine"]["turbine_rating"] + construction_insurance = construction_insurance_per_kW * num_turbines * rating * 1000 + + except: + contruction_insurance_factor = self.project_params.get("construction_insurance_factor", 0.0115) + construction_insurance = (self.turbine_capex + self.bos_capex + self.project_capex) *\ + contruction_insurance_factor + + return construction_insurance + + @property + def decommissioning_capex(self): + """ + Returns the decommissioning capital cost of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review. + """ + + try: + decommissioning_per_kW = self.config["project_parameters"]["decommissioning"] + num_turbines = self.config["plant"]["num_turbines"] + rating = self.config["turbine"]["turbine_rating"] + decommissioning = decommissioning_per_kW * num_turbines * rating * 1000 + + except: + decommissioning_factor = self.project_params.get("decommissioning_factor", 0.175) + decommissioning = self.installation_capex * decommissioning_factor + + return decommissioning + + @property + def project_completion_capex(self): + """ + Returns the project completion capital cost of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review. + """ + + try: + project_completion_per_kW = self.config["project_parameters"]["project_completion"] + num_turbines = self.config["plant"]["num_turbines"] + rating = self.config["turbine"]["turbine_rating"] + project_completion = project_completion_per_kW * num_turbines * rating * 1000 + + except: + project_completion_factor = self.project_params.get("project_completion_factor", 0.0115) + project_completion = (self.turbine_capex + self.bos_capex + self.project_capex) *\ + project_completion_factor + + return project_completion + + @property + def procurement_contingency_capex(self): + """ + Returns the procurement contingency capital cost of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review. + """ + + try: + procurement_contingency_per_kW = self.config["project_parameters"]["procurement_contingency"] + num_turbines = self.config["plant"]["num_turbines"] + rating = self.config["turbine"]["turbine_rating"] + procurement_contingency = procurement_contingency_per_kW * num_turbines * rating * 1000 + + except: + procurement_contingency_factor = self.project_params.get("procurement_contingency_factor", 0.0575) + procurement_contingency = (self.turbine_capex + self.bos_capex + self.project_capex - self.installation_capex) *\ + procurement_contingency_factor + + return procurement_contingency + + @property + def installation_contingency_capex(self): + """ + Returns the installation contingency capital cost of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review. + """ + + try: + installation_contingency_per_kW = self.config["project_parameters"]["installation_contingency"] + num_turbines = self.config["plant"]["num_turbines"] + rating = self.config["turbine"]["turbine_rating"] + installation_contingency = installation_contingency_per_kW * num_turbines * rating * 1000 + + except: + installation_contingency_factor = self.project_params.get("installation_contingency_factor", 0.345) + installation_contingency = self.installation_capex * installation_contingency_factor + + return installation_contingency + + @property + def construction_financing_factor(self): + """ + Returns the construction finaning factor of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review, + except the spend schedule, which is sourced from collaborations with industry. + """ + + try: + spend_schedule = self.config["project_parameters"]["spend_schedule"] + except: + spend_schedule = self.project_params.get("spend_schedule", {0: 0.25, + 1: 0.25, + 2: 0.3, + 3: 0.1, + 4: 0.1, + 5: 0.0 + }) + + try: + tax_rate = self.config["project_parameters"]["tax_rate"] + except: + tax_rate = self.project_params.get("tax_rate", 0.26) + + try: + interest_during_construction = self.config["project_parameters"]["interest_during_construction"] + except: + interest_during_construction = self.project_params.get("interest_during_construction", 0.044) + + + _check = 0 + _construction_financing_factor = 0 + + for key, val in spend_schedule.items(): + _check += val + + _construction_financing_factor += val *\ + (1 + (1 - tax_rate) * ((1 + interest_during_construction) **\ + (key + 0.5) - 1)) + if _check != 1.0: + raise Exception("Values in spend_schedule must sum to 1.0") + + return _construction_financing_factor + + @property + def construction_financing_capex(self): + """ + Returns the construction financing capital cost of the project. + Methodology from ORCA model, default values used in 2022 Cost of Wind Energy Review. + """ + + try: + construction_financing_per_kW = self.config["project_parameters"]["construction_financing"] + num_turbines = self.config["plant"]["num_turbines"] + rating = self.config["turbine"]["turbine_rating"] + construction_financing = construction_financing_per_kW * num_turbines * rating * 1000 + + except: + construction_financing_factor = self.project_params.get("construction_financing_factor", self.construction_financing_factor) + construction_financing = (self.construction_insurance_capex +\ + self.decommissioning_capex +\ + self.project_completion_capex +\ + self.procurement_contingency_capex +\ + self.installation_contingency_capex +\ + self.bos_capex + self.turbine_capex) * (construction_financing_factor - 1) + + + return construction_financing + - return sum( - [ - insurance, - financing, - contingency, - commissioning, - decommissioning, - ], - ) @property def project_capex(self): diff --git a/tests/test_project_manager.py b/tests/test_project_manager.py index 140f36f6..575eb187 100644 --- a/tests/test_project_manager.py +++ b/tests/test_project_manager.py @@ -852,32 +852,93 @@ def test_soft_costs(): config = deepcopy(complete_project) config["project_parameters"] = {"construction_insurance": 50} project = ProjectManager(config) + project.run() assert project.soft_capex != baseline config = deepcopy(complete_project) config["project_parameters"] = {"construction_financing": 190} project = ProjectManager(config) + project.run() assert project.soft_capex != baseline config = deepcopy(complete_project) - config["project_parameters"] = {"contingency": 320} + config["project_parameters"] = {"procurement_contingency": 320} project = ProjectManager(config) + project.run() assert project.soft_capex != baseline config = deepcopy(complete_project) - config["project_parameters"] = {"contingency": 320} + config["project_parameters"] = {"installation_contingency": 320} project = ProjectManager(config) + project.run() assert project.soft_capex != baseline config = deepcopy(complete_project) - config["project_parameters"] = {"commissioning": 50} + config["project_parameters"] = {"decommissioning": 50} project = ProjectManager(config) + project.run() assert project.soft_capex != baseline config = deepcopy(complete_project) - config["project_parameters"] = {"decommissioning": 50} + config["project_parameters"] = {"project_completion": 50} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"construction_insurance_factor": 0.02} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"construction_financing_factor": 1.2} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"spend_schedule": {0: 0, 1: 0, 2: 0, 3: 0, 4: 0.5, 5: 0.5}} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"tax_rate": 0.35} project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"interest_during_construction": 0.06} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"procurement_contingency_factor": 0.1} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"project_completion_factor": 0.3} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"installation_contingency_factor": 0.7} + project = ProjectManager(config) + project.run() + assert project.soft_capex != baseline + + config = deepcopy(complete_project) + config["project_parameters"] = {"decommissioning_factor": 0.7} + project = ProjectManager(config) + project.run() assert project.soft_capex != baseline + def test_project_costs(): @@ -932,9 +993,9 @@ def test_total_capex(): fix_project = ProjectManager(complete_project) fix_project.run() - assert fix_project.total_capex == pytest.approx(1207278397.56, abs=1e-1) + assert fix_project.total_capex == pytest.approx(1216449001.7410436, abs=1e-1) flt_project = ProjectManager(complete_floating_project) flt_project.run() - assert flt_project.total_capex == pytest.approx(3284781912.73, abs=1e-1) + assert flt_project.total_capex == pytest.approx(3540761314.148985, abs=1e-1)