diff --git a/Bioindustrial-Park b/Bioindustrial-Park index 5ba9c259..6ce4a98b 160000 --- a/Bioindustrial-Park +++ b/Bioindustrial-Park @@ -1 +1 @@ -Subproject commit 5ba9c2595089e34495a0d901643bbc458477b0f4 +Subproject commit 6ce4a98bedda8be410e66e41658063ea893926d0 diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 5b343560..8264327c 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -204,10 +204,11 @@ def taxable_and_nontaxable_cashflows( loan_principal = loan_principal_with_interest(loan, interest) else: loan_principal = loan.sum() - LP[:start] = loan * interest LP[start:start + years] = solve_payment(loan_principal, interest, years) taxable_cashflow = S - C - D - LP nontaxable_cashflow = D + Loan - C_FC - C_WC + if not accumulate_interest_during_construction: + nontaxable_cashflow[:start] -= loan * interest else: taxable_cashflow = S - C - D nontaxable_cashflow = D - C_FC - C_WC @@ -224,9 +225,13 @@ def NPV_with_sales( ): """Return NPV with an additional annualized sales.""" taxable_cashflow = taxable_cashflow + sales * sales_coefficients - tax = np.zeros_like(taxable_cashflow) + tax = np.zeros_like(taxable_cashflow, dtype=float) incentives = tax.copy() - fill_tax_and_incentives(incentives, taxable_cashflow, nontaxable_cashflow, tax, depreciation) + fill_tax_and_incentives( + incentives, + taxable_earnings_with_fowarded_losses(taxable_cashflow), + nontaxable_cashflow, tax, depreciation + ) cashflow = nontaxable_cashflow + taxable_cashflow + incentives - tax return (cashflow/discount_factors).sum() @@ -790,8 +795,10 @@ def get_cashflow_table(self): taxable_cashflow = S - C - D nontaxable_cashflow = D - C_FC - C_WC TE[:] = taxable_earnings_with_fowarded_losses(taxable_cashflow) - FL[:] = (taxable_cashflow - TE).cumsum() - self._fill_tax_and_incentives(I, taxable_cashflow, nontaxable_cashflow, T, D) + FL[1:] = (taxable_cashflow - TE).cumsum()[:-1] + self._fill_tax_and_incentives( + I, TE, nontaxable_cashflow, T, D + ) NE[:] = taxable_cashflow + I - T CF[:] = NE + nontaxable_cashflow DF[:] = 1/(1.+self.IRR)**self._get_duration_array() @@ -808,7 +815,11 @@ def NPV(self) -> float: taxable_cashflow, nontaxable_cashflow, depreciation = self._taxable_nontaxable_depreciation_cashflows() tax = np.zeros_like(taxable_cashflow) incentives = tax.copy() - self._fill_tax_and_incentives(incentives, taxable_cashflow, nontaxable_cashflow, tax, depreciation) + self._fill_tax_and_incentives( + incentives, + taxable_earnings_with_fowarded_losses(taxable_cashflow), + nontaxable_cashflow, tax, depreciation + ) cashflow = nontaxable_cashflow + taxable_cashflow + incentives - tax return NPV_at_IRR(self.IRR, cashflow, self._get_duration_array()) @@ -859,14 +870,18 @@ def _taxable_nontaxable_depreciation_cashflows(self): ) def _fill_tax_and_incentives(self, incentives, taxable_cashflow, nontaxable_cashflow, tax, depreciation): - tax[:] = self.income_tax * taxable_earnings_with_fowarded_losses(taxable_cashflow) + tax[:] = self.income_tax * taxable_cashflow def _net_earnings_and_nontaxable_cashflow_arrays(self): taxable_cashflow, nontaxable_cashflow, depreciation = self._taxable_nontaxable_depreciation_cashflows() size = taxable_cashflow.size tax = np.zeros(size) incentives = tax.copy() - self._fill_tax_and_incentives(incentives, taxable_cashflow, nontaxable_cashflow, tax, depreciation) + self._fill_tax_and_incentives( + incentives, + taxable_earnings_with_fowarded_losses(taxable_cashflow), + nontaxable_cashflow, tax, depreciation + ) net_earnings = taxable_cashflow + incentives - tax return net_earnings, nontaxable_cashflow @@ -984,12 +999,11 @@ def solve_sales(self): """ discount_factors = (1 + self.IRR)**self._get_duration_array() - sales_coefficients = np.ones_like(discount_factors) + sales_coefficients = np.ones_like(discount_factors, dtype=float) start = self._start sales_coefficients[:start] = 0 w0 = self._startup_time - sales_coefficients[self._start] = w0*self.startup_VOCfrac + (1-w0) - sales = self._sales + sales_coefficients[start] = w0*self.startup_salesfrac + (1.-w0) taxable_cashflow, nontaxable_cashflow, depreciation = self._taxable_nontaxable_depreciation_cashflows() if np.isnan(taxable_cashflow).any(): warn('nan encountered in cashflow array; resimulating system', category=RuntimeWarning) @@ -1003,17 +1017,16 @@ def solve_sales(self): sales_coefficients, discount_factors, self._fill_tax_and_incentives) - x0 = sales + x0 = self._sales if np.isfinite(self._sales) else 0 f = NPV_with_sales - if not np.isfinite(x0): x0 = 0. y0 = f(x0, *args) x1 = x0 - y0 / self._years # First estimate try: - sales = flx.aitken_secant(f, x0, x1, xtol=10, ytol=1000., + sales = flx.aitken_secant(f, x0, x1, xtol=10, ytol=100., maxiter=1000, args=args, checkiter=True) except: bracket = flx.find_bracket(f, x0, x1, args=args) - sales = flx.IQ_interpolation(f, *bracket, args=args, xtol=10, ytol=1000, maxiter=1000, checkiter=False) + sales = flx.IQ_interpolation(f, *bracket, args=args, xtol=10, ytol=100, maxiter=1000, checkiter=False) self._sales = sales return sales diff --git a/tests/test_tea.py b/tests/test_tea.py index aad8ff70..282cd02e 100644 --- a/tests/test_tea.py +++ b/tests/test_tea.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # BioSTEAM: The Biorefinery Simulation and Techno-Economic Analysis Modules -# Copyright (C) 2021-2022, Yalin Li +# Copyright (C) 2021-2024, Yalin Li # # This module is under the UIUC open-source license. See # github.com/BioSTEAMDevelopmentGroup/biosteam/blob/master/LICENSE.txt @@ -8,9 +8,10 @@ """ """ -import pytest, numpy as np +import pytest, numpy as np, biosteam as bst from numpy.testing import assert_allclose -from biosteam import TEA, System +from biosteam import TEA, System, Stream, Unit, Chemical, settings +from biorefineries.tea import create_cellulosic_ethanol_tea def test_depreciation_schedule(): correct_arrs = { @@ -51,6 +52,212 @@ def test_depreciation_schedule(): with pytest.raises(ValueError): tea.depreciation = 'bad' +def test_cashflow_consistency(): + settings.set_thermo([ + Chemical('Dummy', default=True, phase='s', MW=1, search_db=False) + ]) + + cost = Stream(Dummy=9793.983511363867 - 1280.916880939845, price=1) # Includes co-product electricity credits + ethanol = Stream(flow=[21978.374283953395], price=0.7198608114634679) + + class MockCellulosicEthanolBiorefinery(Unit): + _N_ins = _N_outs = 1 + + def _run(self): pass + + def _cost(self): + self.baseline_purchase_costs['Biorefinery'] = 85338080.48935215 + + class OSBL(Unit): + N_outs = _N_ins = 0 + + def _run(self): pass + + def _cost(self): + self.baseline_purchase_costs['Biorefinery'] = 122287135.13152598 + + unit = MockCellulosicEthanolBiorefinery(ins=cost, outs=ethanol) + osbl = OSBL() + sys = System.from_units(units=[unit, osbl]) + sys.simulate() + tea = create_cellulosic_ethanol_tea(sys, OSBL_units=[osbl]) + table = tea.get_cashflow_table() + assert_allclose(tea.NPV, 32131936.781448975) + assert_allclose(tea.NPV, table['Cumulative NPV [MM$]'].iloc[-1]*1e6) + tea.IRR = tea.solve_IRR() + assert_allclose(tea.NPV, 0, atol=100) + assert_allclose(tea.IRR, 0.11246761316144724) + tea.IRR = 0.10 + ethanol.price = tea.solve_price(ethanol) + assert_allclose(ethanol.price, 0.6952016482242149) + assert_allclose(tea.NPV, 0, atol=100) + +def test_tea(): + cost = bst.decorators.cost + # Total installed equipment cost to be $1 MM + bst.CE = CE = bst.design_tools.CEPCI_by_year[2013] + @cost('Fake scaler', 'Lumped cost', CE=CE, cost=1e6, S=1, n=1, BM=1) + class LumpedCost(bst.Unit): + '''Does nothing but adding given costs.''' + _units = {'Fake scaler': ''} + + def _design(self): + self.design_results['Fake scaler'] = 1 + + class TEA(bst.TEA): + def __init__( + self, + system, + FOC_over_installed=0.5, # annual O&M + DPI_over_installed=(1+1), + TDC_over_DPI=(1+0.2)*(1+0.4), # 20% non-installed & 40% indirect + FCI_over_TDC=1, + **kwargs, + ): + self.FOC_over_installed = FOC_over_installed + self.DPI_over_installed = DPI_over_installed + self.TDC_over_DPI = TDC_over_DPI + self.FCI_over_TDC = FCI_over_TDC + bst.TEA.__init__(self, system, **kwargs) + + def _FOC(self, installed_equipment_cost): # fixed operating cost + return installed_equipment_cost*self.FOC_over_installed + + def _DPI(self, installed_equipment_cost): # direct permanent investment + return installed_equipment_cost*self.DPI_over_installed + + def _TDC(self, DPI): # total depreciable cost + return DPI*self.TDC_over_DPI + + def _FCI(self, TDC): # fixed capital investment + return TDC*self.FCI_over_TDC + + bst.settings.set_thermo([bst.Chemical('Water')]) + reactant = bst.Stream('reactant', Water=1, units='kg/hr') + # Total annual sales to be $2.5 MM + product = bst.Stream('product', Water=1, price=2.5e6/365/24, units='kg/hr') + + U101 = LumpedCost('U101', ins=reactant, outs=product) + sys = bst.System('sys', path=(U101,)) + sys.simulate() + + tea = TEA( + system=sys, + IRR=0.1, + duration=(2013, 2013+15), # 15 years + income_tax=0.21+0.1, + construction_schedule=(1,), + depreciation='MACRS7', + operating_days=365, + startup_months=0, + startup_FOCfrac=1, + startup_VOCfrac=1, + startup_salesfrac=1, + lang_factor=None, + WC_over_FCI=0.05, + finance_interest=0.08, + finance_years=10, + finance_fraction =0.6, + accumulate_interest_during_construction=False, + ) + + # Below test NPV and discounted cashflow calculation + table = tea.get_cashflow_table() + data = np.array([ + # Depreciable capital [MM$] + # Fixed capital investment [MM$] + # Working capital [MM$] + # Depreciation [MM$] + # Loan [MM$] + [ 3.36 , 3.36 , 0.168 , 0. , 2.016 , + # Loan interest payment [MM$] + # Loan payment [MM$] + # Loan principal [MM$] + # Annual operating cost (excluding depreciation) [MM$] + # Sales [MM$] + 0.16128 , 0. , 2.016 , 0. , 0. , + # Tax [MM$] + # Incentives [MM$] + # Taxed earnings [MM$] + # Forwarded losses [MM$] + # Net earnings [MM$] + 0. , 0. , 0. , 0. , 0. , + # Cash flow [MM$] + # Discount factor + # Net present value (NPV) [MM$] + # Cumulative NPV [MM$] + -1.67328 , 1. , -1.67328 , -1.67328 ], + [ 0. , 0. , 0. , 0.480144 , 0. , + 0.16128 , 0.30044345, 1.87683655, 1.68 , 2.5 , + 0.01221789, 0. , 0.03941255, 0. , 0.02719466, + 0.50733866, 0.90909091, 0.46121696, -1.21206304], + [ 0. , 0. , 0. , 0.822864 , 0. , + 0.15014692, 0.30044345, 1.72654003, 1.68 , 2.5 , + 0. , 0. , 0. , 0. , -0.30330745, + 0.51955655, 0.82644628, 0.42938558, -0.78267746], + [ 0. , 0. , 0. , 0.587664 , 0. , + 0.1381232 , 0.30044345, 1.56421978, 1.68 , 2.5 , + 0. , 0. , 0. , -0.30330745, -0.06810745, + 0.51955655, 0.7513148 , 0.39035053, -0.39232693], + [ 0. , 0. , 0. , 0.419664 , 0. , + 0.12513758, 0.30044345, 1.38891391, 1.68 , 2.5 , + 0. , 0. , 0. , -0.3714149 , 0.09989255, + 0.51955655, 0.68301346, 0.35486412, -0.03746282], + [ 0. , 0. , 0. , 0.300048 , 0. , + 0.11111311, 0.30044345, 1.19958358, 1.68 , 2.5 , + 0. , 0. , 0. , -0.27152235, 0.21950855, + 0.51955655, 0.62092132, 0.32260374, 0.28514093], + [ 0. , 0. , 0. , 0.299712 , 0. , + 0.09596669, 0.30044345, 0.99510681, 1.68 , 2.5 , + 0.05202753, 0. , 0.16783075, -0.0520138 , 0.16781702, + 0.46752902, 0.56447393, 0.26390794, 0.54904887], + [ 0. , 0. , 0. , 0.300048 , 0. , + 0.07960854, 0.30044345, 0.77427191, 1.68 , 2.5 , + 0.06804765, 0. , 0.21950855, 0. , 0.1514609 , + 0.4515089 , 0.51315812, 0.23169546, 0.78074432], + [ 0. , 0. , 0. , 0.149856 , 0. , + 0.06194175, 0.30044345, 0.53577021, 1.68 , 2.5 , + 0.11460717, 0. , 0.36970055, 0. , 0.25509338, + 0.40494938, 0.46650738, 0.18891187, 0.9696562 ], + [ 0. , 0. , 0. , 0. , 0. , + 0.04286162, 0.30044345, 0.27818838, 1.68 , 2.5 , + 0.16106253, 0. , 0.51955655, 0. , 0.35849402, + 0.35849402, 0.42409762, 0.15203646, 1.12169266], + [ 0. , 0. , 0. , 0. , 0. , + 0.02225507, 0.30044345, 0. , 1.68 , 2.5 , + 0.16106253, 0. , 0.51955655, 0. , 0.35849402, + 0.35849402, 0.38554329, 0.13821496, 1.25990762], + [ 0. , 0. , 0. , 0. , 0. , + 0. , 0. , 0. , 1.68 , 2.5 , + 0.2542 , 0. , 0.82 , 0. , 0.5658 , + 0.5658 , 0.3504939 , 0.19830945, 1.45821707], + [ 0. , 0. , 0. , 0. , 0. , + 0. , 0. , 0. , 1.68 , 2.5 , + 0.2542 , 0. , 0.82 , 0. , 0.5658 , + 0.5658 , 0.31863082, 0.18028132, 1.63849839], + [ 0. , 0. , 0. , 0. , 0. , + 0. , 0. , 0. , 1.68 , 2.5 , + 0.2542 , 0. , 0.82 , 0. , 0.5658 , + 0.5658 , 0.28966438, 0.16389211, 1.80239049], + [ 0. , 0. , 0. , 0. , 0. , + 0. , 0. , 0. , 1.68 , 2.5 , + 0.2542 , 0. , 0.82 , 0. , 0.5658 , + 0.5658 , 0.26333125, 0.14899282, 1.95138332], + [ 0. , 0. , -0.168 , 0. , 0. , + 0. , 0. , 0. , 1.68 , 2.5 , + 0.2542 , 0. , 0.82 , 0. , 0.5658 , + 0.7338 , 0.23939205, 0.17566589, 2.1270492 ]]) + + assert_allclose(table.values, data, atol=1e-4) + assert_allclose(tea.NPV, table.iloc[-1,-1]*1e6, atol=1e-4) + total_interest_payment1 = table['Loan payment [MM$]'].sum() + total_interest_payment2 = ( + table['Loan interest payment [MM$]'].iloc[1:].sum() + # payment during construction (year 0) is equity/cash + table['Loan principal [MM$]'].iloc[0]) + assert_allclose(total_interest_payment1, total_interest_payment2, atol=1e-4) + if __name__ == '__main__': - test_depreciation_schedule() \ No newline at end of file + test_depreciation_schedule() + test_cashflow_consistency() + test_tea()