From 8fafa297137e3db6db2cd159291ca63433422f27 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 27 May 2026 16:33:24 +0200 Subject: [PATCH 1/9] Add functions to calculate multiplicative factors arrays for discount/update values --- biosteam/_tea.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 34cb4c6f..0f1ebaca 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -220,6 +220,16 @@ def taxable_and_nontaxable_cashflows( nontaxable_cashflow = D - C_FC - C_WC return taxable_cashflow, nontaxable_cashflow +def build_nominal_factors_operating(factors, start, years, rate): + operating_index = np.arange(years) + factors[start:] = (1.0 + rate)**operating_index + return factors + +def build_nominal_factors_construction(factors, start, rate): + construction_index = np.arange(start, 0, -1) + factors[:start] = (1.0 + rate)**(-construction_index) + return factors + def NPV_with_sales( sales, taxable_cashflow, From d3c38f35363d5cce6afc3e50c372988fe5fc0e36 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 27 May 2026 18:24:07 +0200 Subject: [PATCH 2/9] Adapt build function; Add inflation consideration in _taxable_nontaxable_depreciation function The previous functions to build the inflation factors were deteletd. The inflation is now considered to go from the first year of the project (either construction or operation if no contruction is assumed) to the end year. This decision was made to scalate CAPEX following plant design and economics for chemical engineers book by Peters, Timmerhaus and West --- biosteam/_tea.py | 81 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 0f1ebaca..2ae6967f 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -182,6 +182,7 @@ def taxable_and_nontaxable_cashflows( start, years, lang_factor, accumulate_interest_during_construction, + f_inflation, ): # Cash flow data and parameters # C_FC: Fixed capital @@ -202,6 +203,13 @@ def taxable_and_nontaxable_cashflows( ) for i in unit_capital_costs: add_all_replacement_costs_to_cashflow_array(i, C_FC, years, start, lang_factor) + + # Multiply for inflation factors, if inflation None the factors are 1. + C *= f_inflation + S *= f_inflation + C_FC *= f_inflation + C_WC *= f_inflation + if finance_interest: interest = finance_interest years = finance_years @@ -220,14 +228,9 @@ def taxable_and_nontaxable_cashflows( nontaxable_cashflow = D - C_FC - C_WC return taxable_cashflow, nontaxable_cashflow -def build_nominal_factors_operating(factors, start, years, rate): - operating_index = np.arange(years) - factors[start:] = (1.0 + rate)**operating_index - return factors - -def build_nominal_factors_construction(factors, start, rate): - construction_index = np.arange(start, 0, -1) - factors[:start] = (1.0 + rate)**(-construction_index) +def build_nominal_factors(factors, rate): + operating_index = np.arange(factors.size) + factors[:] = (1.0 + rate)**operating_index return factors def NPV_with_sales( @@ -343,7 +346,8 @@ class TEA: '_startup_schedule', '_operating_days', '_duration', '_depreciation_key', '_depreciation', '_years', '_duration', '_start', 'IRR', '_IRR', '_sales', - '_duration_array_cache', 'accumulate_interest_during_construction') + '_duration_array_cache', 'accumulate_interest_during_construction', + '_inflation_rate', '_f_inflation') #: Available depreciation schedules. Defaults include modified #: accelerated cost recovery system from U.S. IRS publication 946 (MACRS), @@ -456,7 +460,8 @@ def __init__(self, system: System, IRR: float, duration: tuple[int, int], startup_months: float, startup_FOCfrac: float, startup_VOCfrac: float, startup_salesfrac: float, WC_over_FCI: float, finance_interest: float, finance_years: int, finance_fraction: float, - accumulate_interest_during_construction: bool=False): + accumulate_interest_during_construction: bool=False, + inflation_rate: float|NDArray[float] = None): #: System being evaluated. self.system: System = system @@ -504,6 +509,11 @@ def __init__(self, system: System, IRR: float, duration: tuple[int, int], #: Whether to immediately pay interest before operation or to accumulate interest during construction self.accumulate_interest_during_construction = accumulate_interest_during_construction + # Inflation + self._inflation_rate = None + self._f_inflation = None + self.inflation_rate = inflation_rate + #: For convenience, set a TEA attribute for the system system._TEA = self @@ -738,6 +748,46 @@ def PBP(self) -> float: net_earnings = self.net_earnings return FCI/net_earnings + @property + def inflation_rate(self): + return self._inflation_rate + + @inflation_rate.setter + def inflation_rate(self, value): + self._inflation_rate = value + self._f_inflation = self._build_f_inflation() + + @property + def f_inflation(self): + return self._f_inflation + + def _build_f_inflation(self): + """Nominal inflation factors aligned with TEA cashflow arrays""" + length = self._start + self._years + inflation = self.inflation_rate + + if inflation is None: + return np.ones(length, dtype=float) + + if isinstance(inflation, (int, float)): + return build_nominal_factors(np.ones(length, dtype=float), inflation) + + if isinstance(inflation, np.ndarray): + factors = inflation + + if factors.ndim != 1: + raise ValueError("inflation factors must be a 1D array.") + + if factors.size != length: + raise ValueError(f"inflation factors must have length {length}; got {factors.size}.") + + if not np.isclose(factors[0], 1.0): + raise ValueError("inflation factor at index 0 must be 1.0.") + + return factors + + raise TypeError("inflation_rate must be None, float annual rate or 1D numpy array of nominal factors.") + def _get_duration_array(self): key = start, years = (self._start, self._years) if key in _duration_array_cache: @@ -896,21 +946,23 @@ def _taxable_nontaxable_depreciation_cashflows(self): # S: Sales # NE: Net earnings # CF: Cash flow - TDC = self.TDC - FCI = self._FCI(TDC) + TDC0 = self.TDC + FCI = self._FCI(TDC0) start = self._start years = self._years + inflation_factors = self.f_inflation + TDC_nom = (TDC0 * self.construction_schedule * inflation_factors[:start]).sum() FOC = self._FOC(FCI) VOC = self.VOC D, C_FC, C_WC, Loan, LP, C, S = np.zeros((7, start + years)) - self._fill_depreciation_array(D, start, years, TDC) + self._fill_depreciation_array(D, start, years, TDC_nom) WC = self.WC_over_FCI * FCI system = self.system return ( *taxable_and_nontaxable_cashflows( system.unit_capital_costs if isinstance(system, bst.AgileSystem) else system.cost_units, D, C, S, C_FC, C_WC, Loan, LP, - FCI, WC, TDC, VOC, FOC, self.sales, + FCI, WC, TDC0, VOC, FOC, self.sales, self._startup_time, self.startup_VOCfrac, self.startup_FOCfrac, @@ -922,6 +974,7 @@ def _taxable_nontaxable_depreciation_cashflows(self): start, years, self.lang_factor, self.accumulate_interest_during_construction, + inflation_factors, ), D ) From c5945ff9ea145f2f710199523ee2d0df338c899e Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 27 May 2026 18:48:48 +0200 Subject: [PATCH 3/9] Add inflation to get_cashflow_table --- biosteam/_tea.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 2ae6967f..1c42a21f 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -838,16 +838,16 @@ def get_cashflow_table(self): # DF: Discount factor # NPV: Net present value # CNPV: Cumulative NPV - TDC = self.TDC - FCI = self._FCI(TDC) + TDC0 = self.TDC + FCI = self._FCI(TDC0) start = self._start years = self._years + f_inflation = self.f_inflation FOC = self._FOC(FCI) VOC = self.VOC sales = self.sales length = start + years C_D, C_FC, C_WC, D, L, LI, LP, LPl, C, S, T, I, TE, FL, NE, CF, DF, NPV, CNPV = data = np.zeros((19, length)) - self._fill_depreciation_array(D, start, years, TDC) w0 = self._startup_time % 1 w1 = 1. - w0 end_start = start + int(self._startup_time) @@ -860,15 +860,21 @@ def get_cashflow_table(self): start1 = end_start + 1 C[start1:] = VOC + FOC S[start1:] = sales + C *= f_inflation + S *= f_inflation WC = self.WC_over_FCI * FCI - C_D[:start] = TDC*self._construction_schedule + C_D[:start] = TDC0*self._construction_schedule + C_D *= f_inflation + self._fill_depreciation_array(D, start, years, C_D[:start].sum()) C_FC[:start] = FCI*self._construction_schedule C_WC[start-1] = WC C_WC[-1] = -WC + C_WC *= f_inflation system = self.system lang_factor = system.lang_factor unit_capital_costs = system.unit_capital_costs.values() if isinstance(system, bst.AgileSystem) else system.cost_units for i in unit_capital_costs: add_all_replacement_costs_to_cashflow_array(i, C_FC, years, start, lang_factor) + C_FC *= f_inflation if self.finance_interest: interest = self.finance_interest years = self.finance_years From caec6282690d06be7fb22ecf4817ab1a6de076bf Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 27 May 2026 18:55:04 +0200 Subject: [PATCH 4/9] Add inflation to sales in solve sales --- biosteam/_tea.py | 1 + 1 file changed, 1 insertion(+) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 1c42a21f..b2580106 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -1150,6 +1150,7 @@ def solve_sales(self): end_start = start + int(self._startup_time) sales_coefficients[end_start] = w0 * self.startup_salesfrac + (1. - w0) sales_coefficients[start:end_start] = self.startup_salesfrac + sales_coefficients *= self.f_inflation 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) From df0ce9c4c80363f2f3a88ff64e86e75029713f4b Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 27 May 2026 19:02:59 +0200 Subject: [PATCH 5/9] rebuild inflation factors if construction schedule or duration are modified --- biosteam/_tea.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index b2580106..f4c0d814 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -587,6 +587,7 @@ def duration(self, duration): start, end = [int(i) for i in duration] self._duration = (start, end) self._years = end - start + self._f_inflation = self._build_f_inflation() @property def depreciation(self) -> str|NDArray[float]: @@ -664,6 +665,7 @@ def construction_schedule(self) -> Sequence[float]: def construction_schedule(self, schedule): self._construction_schedule = np.array(schedule, dtype=float) self._start = len(schedule) + self._f_inflation = self._build_f_inflation() @property def startup_months(self) -> float: From f62984a42ac060a5a5755d187bea99466615fa6a Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 28 May 2026 11:53:30 +0200 Subject: [PATCH 6/9] Add feature to transform IRR from nominal to real if inflation is provided --- biosteam/_tea.py | 68 ++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index f4c0d814..68f9067e 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -347,7 +347,7 @@ class TEA: '_duration', '_depreciation_key', '_depreciation', '_years', '_duration', '_start', 'IRR', '_IRR', '_sales', '_duration_array_cache', 'accumulate_interest_during_construction', - '_inflation_rate', '_f_inflation') + '_inflation_rate', '_inflation_factors') #: Available depreciation schedules. Defaults include modified #: accelerated cost recovery system from U.S. IRS publication 946 (MACRS), @@ -461,16 +461,23 @@ def __init__(self, system: System, IRR: float, duration: tuple[int, int], startup_salesfrac: float, WC_over_FCI: float, finance_interest: float, finance_years: int, finance_fraction: float, accumulate_interest_during_construction: bool=False, - inflation_rate: float|NDArray[float] = None): + inflation_rate: float|None = None): #: System being evaluated. self.system: System = system + # Inflation + self._inflation_rate = None + self._inflation_factors = None + + # Time periods self.duration = duration self.depreciation = depreciation self.construction_schedule = construction_schedule self.startup_months = startup_months self.operating_days = operating_days + self.update_inflation_factors() + #: Internal rate of return (fraction). self.IRR: float = IRR @@ -508,11 +515,6 @@ def __init__(self, system: System, IRR: float, duration: tuple[int, int], #: Whether to immediately pay interest before operation or to accumulate interest during construction self.accumulate_interest_during_construction = accumulate_interest_during_construction - - # Inflation - self._inflation_rate = None - self._f_inflation = None - self.inflation_rate = inflation_rate #: For convenience, set a TEA attribute for the system system._TEA = self @@ -587,7 +589,7 @@ def duration(self, duration): start, end = [int(i) for i in duration] self._duration = (start, end) self._years = end - start - self._f_inflation = self._build_f_inflation() + self.update_inflation_factors() @property def depreciation(self) -> str|NDArray[float]: @@ -665,7 +667,7 @@ def construction_schedule(self) -> Sequence[float]: def construction_schedule(self, schedule): self._construction_schedule = np.array(schedule, dtype=float) self._start = len(schedule) - self._f_inflation = self._build_f_inflation() + self.update_inflation_factors() @property def startup_months(self) -> float: @@ -752,18 +754,24 @@ def PBP(self) -> float: @property def inflation_rate(self): + """Inflation rate to escalate cashflows to nominal dollars""" return self._inflation_rate @inflation_rate.setter def inflation_rate(self, value): self._inflation_rate = value - self._f_inflation = self._build_f_inflation() + self.update_inflation_factors() @property - def f_inflation(self): - return self._f_inflation + def inflation_factors(self): + """Multiplicative factors to scalate cashflows""" + return self._inflation_factors - def _build_f_inflation(self): + def update_inflation_factors(self): + """Update inflation factors""" + self._inflation_factors = self._build_inflation_factors() + + def _build_inflation_factors(self): """Nominal inflation factors aligned with TEA cashflow arrays""" length = self._start + self._years inflation = self.inflation_rate @@ -773,22 +781,14 @@ def _build_f_inflation(self): if isinstance(inflation, (int, float)): return build_nominal_factors(np.ones(length, dtype=float), inflation) - - if isinstance(inflation, np.ndarray): - factors = inflation - if factors.ndim != 1: - raise ValueError("inflation factors must be a 1D array.") - - if factors.size != length: - raise ValueError(f"inflation factors must have length {length}; got {factors.size}.") - - if not np.isclose(factors[0], 1.0): - raise ValueError("inflation factor at index 0 must be 1.0.") - - return factors - - raise TypeError("inflation_rate must be None, float annual rate or 1D numpy array of nominal factors.") + raise TypeError("inflation_rate must be None or float annual rate.") + + def _get_discount_rate(self): + """Return nominal IRR when inflation is active, otherwise real IRR""" + if self.inflation_rate is None: + return self.IRR + return (1.0 + self.IRR) * (1.0 + self.inflation_rate) - 1.0 def _get_duration_array(self): key = start, years = (self._start, self._years) @@ -844,7 +844,7 @@ def get_cashflow_table(self): FCI = self._FCI(TDC0) start = self._start years = self._years - f_inflation = self.f_inflation + f_inflation = self.inflation_factors FOC = self._FOC(FCI) VOC = self.VOC sales = self.sales @@ -916,7 +916,7 @@ def get_cashflow_table(self): ) NE[:] = taxable_cashflow + I - T CF[:] = NE + nontaxable_cashflow - DF[:] = 1/(1.+self.IRR)**self._get_duration_array() + DF[:] = 1/(1.+self._get_discount_rate())**self._get_duration_array() NPV[:] = CF * DF CNPV[:] = NPV.cumsum() DF *= 1e6 @@ -936,7 +936,7 @@ def NPV(self) -> float: nontaxable_cashflow, tax, depreciation ) cashflow = nontaxable_cashflow + taxable_cashflow + incentives - tax - return NPV_at_IRR(self.IRR, cashflow, self._get_duration_array()) + return NPV_at_IRR(self._get_discount_rate(), cashflow, self._get_duration_array()) def _AOC(self, FCI): """Return AOC at given FCI""" @@ -958,7 +958,7 @@ def _taxable_nontaxable_depreciation_cashflows(self): FCI = self._FCI(TDC0) start = self._start years = self._years - inflation_factors = self.f_inflation + inflation_factors = self.inflation_factors TDC_nom = (TDC0 * self.construction_schedule * inflation_factors[:start]).sum() FOC = self._FOC(FCI) VOC = self.VOC @@ -1144,7 +1144,7 @@ def solve_sales(self): point (NPV = 0) through cash flow analysis. """ - discount_factors = (1 + self.IRR)**self._get_duration_array() + discount_factors = (1 + self._get_discount_rate())**self._get_duration_array() sales_coefficients = np.ones_like(discount_factors, dtype=float) start = self._start sales_coefficients[:start] = 0 @@ -1152,7 +1152,7 @@ def solve_sales(self): end_start = start + int(self._startup_time) sales_coefficients[end_start] = w0 * self.startup_salesfrac + (1. - w0) sales_coefficients[start:end_start] = self.startup_salesfrac - sales_coefficients *= self.f_inflation + sales_coefficients *= self.inflation_factors 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) From f02a802ac4c9992fa7194e3140b0dc4f21df0cca Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 28 May 2026 12:48:00 +0200 Subject: [PATCH 7/9] Document inflation-related changes; Modify solve_IRR to give real IRR even solving nominal IRR --- biosteam/_tea.py | 80 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 68f9067e..9d96575b 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -287,7 +287,10 @@ class TEA: system : Should contain feed and product streams. IRR : - Internal rate of return (fraction). + Real internal rate of return (fraction). If `inflation_rate` is given, + cashflows are escalated to nominal values and this real IRR is internally + converted to a nominal dicount rate for NPV calculations. This is done by + using Fisher equation: nom_IRR = (1 + IRR)*(1 + inflation_rate) - 1. duration : Start and end year of venture (e.g. (2018, 2038)). depreciation : @@ -324,6 +327,14 @@ class TEA: Number of years the loan is paid for. finance_fraction : Fraction of capital cost that needs to be financed. + inflation_rate : + Annual constant inflation rate as a fraction. If provided, + operating costs, sales, capital expenses, replacement costs + and working capital flows are escalated to nominal dollars + using this rate. NPV calculations then use a nominal + discount rate computed from `IRR` and `inflation_rate`. + If `None`, no inflation is applied and cashflows are treated + as base-year dollars. Warning ------- @@ -466,7 +477,7 @@ def __init__(self, system: System, IRR: float, duration: tuple[int, int], self.system: System = system # Inflation - self._inflation_rate = None + self._inflation_rate = inflation_rate self._inflation_factors = None # Time periods @@ -754,7 +765,7 @@ def PBP(self) -> float: @property def inflation_rate(self): - """Inflation rate to escalate cashflows to nominal dollars""" + """Annual constant inflation rate used to escalate cashflows to nominal dollars""" return self._inflation_rate @inflation_rate.setter @@ -764,15 +775,22 @@ def inflation_rate(self, value): @property def inflation_factors(self): - """Multiplicative factors to scalate cashflows""" + """Multiplicative nominal escalation factors aligned with the cashflow array""" return self._inflation_factors def update_inflation_factors(self): """Update inflation factors""" - self._inflation_factors = self._build_inflation_factors() + if hasattr(self,"_start") and hasattr(self, "_years"): + self._inflation_factors = self._build_inflation_factors() def _build_inflation_factors(self): - """Nominal inflation factors aligned with TEA cashflow arrays""" + """ + Build nominal escalation factors for all construction and operating years. + + Returns an array of length `self._start + self._years`. If `inflation_rate` + is None, all factors are 1. Otherwise, factors are compounded as: + factor[t] = (1 + inflation_rate)**t + """ length = self._start + self._years inflation = self.inflation_rate @@ -785,7 +803,7 @@ def _build_inflation_factors(self): raise TypeError("inflation_rate must be None or float annual rate.") def _get_discount_rate(self): - """Return nominal IRR when inflation is active, otherwise real IRR""" + """Return the discount rate consistent with the cashflow basis.""" if self.inflation_rate is None: return self.IRR return (1.0 + self.IRR) * (1.0 + self.inflation_rate) - 1.0 @@ -818,7 +836,17 @@ def _fill_depreciation_array(self, D, start, years, TDC): D[start:start + N_depreciation_years] = TDC * depreciation_array def get_cashflow_table(self): - """Return DataFrame of the cash flow analysis.""" + """ + Return DataFrame of the cash flow analysis. + + If `inflation_rate` is provided annual, annual costs, sales, capital expenses, + replacement costs and working capital are reported in nominal dollars. + Discount factors are computed using the nominal discount rate derived from + the real `IRR` and `inflation_rate`. + + If `inflation_rate` is None, values are reported in real or base-year dollars and + discounted directly with `IRR`. + """ # Cash flow data and parameters # index: Year since construction until end of venture # C_D: Depreciable capital @@ -926,7 +954,13 @@ def get_cashflow_table(self): columns=cashflow_columns) @property def NPV(self) -> float: - """Net present value.""" + """ + Net present value. + + Uses cashflows consistent with the inflation setting. With inflation, cashflows are + nominal and discounted with the internally computed nominal discount rate. without + inflation, cashflows are treated as base-year values and discounted with `IRR`. + """ taxable_cashflow, nontaxable_cashflow, depreciation = self._taxable_nontaxable_depreciation_cashflows() tax = np.zeros_like(taxable_cashflow) incentives = tax.copy() @@ -1061,8 +1095,23 @@ def total_production_cost(self, products: Collection[bst.Stream], with_annual_de return self.AOC - coproduct_sales def solve_IRR(self, financing=True, bounds=None): - """Return the IRR at the break even point (NPV = 0) through cash flow analysis.""" + """ + Return the real internal rate of return at the break-even point. + + If `inflation_rate` is provided, this method solves IRR for inflated + cashflows and the resulting nominal IRR is converted to real IRR applying + Fisher equation: real_IRR = (1 + nominal_IRR)/(1 + inflation_rate) - 1. + + If bounds are provided and `inflation_rate` is not None, bounds must be given + as nominal IRR values because the solver operates on nominal cashflows. + + """ IRR = self._IRR + + # Use nominal IRR as initial guess to solve nominal cashflows + if self.inflation_rate is not None: + IRR = (1 + IRR)*(1 + self.inflation_rate) - 1 + if not IRR or np.isnan(IRR) or IRR < 0.: IRR = 0.01 if financing: args = (self.cashflow_array, self._get_duration_array()) @@ -1093,6 +1142,11 @@ def solve_IRR(self, financing=True, bounds=None): ) finally: self.finance_fraction, self.finance_interest = financing_values + + # convert nominal IRR to real IRR + if self.inflation_rate is not None: + IRR = (1 + IRR)/(1 + self.inflation_rate) - 1 + self._IRR = IRR return IRR @@ -1141,7 +1195,11 @@ def FOC_table(self): def solve_sales(self): """ Return the required additional sales [USD] to reach the breakeven - point (NPV = 0) through cash flow analysis. + point (NPV = 0) through cash flow analysis. + + The returned value is expressed in base-year dollars. If `inflation_rate` + is provided, this additional sales value is escalated through time using + `inflation_factors` before calculating NPV. """ discount_factors = (1 + self._get_discount_rate())**self._get_duration_array() From 2a71970fa075c3962d64c421b3f38143e9f98ca5 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 28 May 2026 13:23:44 +0200 Subject: [PATCH 8/9] Add test to check inflation implementation using the same data of test_tea --- tests/test_tea.py | 115 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/tests/test_tea.py b/tests/test_tea.py index c3448b0b..f82c6e9f 100644 --- a/tests/test_tea.py +++ b/tests/test_tea.py @@ -337,6 +337,118 @@ def _FCI(self, TDC): # fixed capital investment table['Loan principal [MM$]'].iloc[0]) assert_allclose(total_interest_payment1, total_interest_payment2, atol=1e-4) +def test_tea_with_inflation(): + cost = bst.decorators.cost + 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, + DPI_over_installed=2, + TDC_over_DPI=1.2*1.4, + 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): + return installed_equipment_cost * self.FOC_over_installed + + def _DPI(self, installed_equipment_cost): + return installed_equipment_cost * self.DPI_over_installed + + def _TDC(self, DPI): + return DPI * self.TDC_over_DPI + + def _FCI(self, TDC): + return TDC * self.FCI_over_TDC + + bst.settings.set_thermo([bst.Chemical('Water')]) + reactant = bst.Stream('reactant', Water=1, units='kg/hr') + 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_inflation', path=(U101,)) + sys.simulate() + + IRR = 0.10 + inflation_rate = 0.03 + nominal_IRR = (1 + IRR) * (1 + inflation_rate) - 1 + + tea = TEA( + system=sys, + IRR=IRR, + inflation_rate=inflation_rate, + duration=(2013, 2013+15), + income_tax=0.31, + 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, + ) + + table = tea.get_cashflow_table() + factors = (1 + inflation_rate) ** np.arange(tea._start + tea._years) + + assert_allclose(tea.inflation_factors, factors) + assert_allclose(tea._get_discount_rate(), nominal_IRR) + + # Check nominal escalation of representative cashflows. + assert_allclose(table['Sales [MM$]'].values[0], 0.0) + assert_allclose(table['Sales [MM$]'].values[1:], 2.5 * factors[1:]) + + assert_allclose(table['Annual operating cost (excluding depreciation) [MM$]'].values[0], 0.0) + assert_allclose( + table['Annual operating cost (excluding depreciation) [MM$]'].values[1:], + 1.68 * factors[1:], + ) + + # Check construction-year capital and final working capital recovery. + assert_allclose(table['Fixed capital investment [MM$]'].iloc[0], 3.36) + assert_allclose(table['Working capital [MM$]'].iloc[0], 0.168) + assert_allclose(table['Working capital [MM$]'].iloc[-1], -0.168 * factors[-1]) + + # Check nominal discounting. + assert_allclose( + table['Discount factor'], + 1 / (1 + nominal_IRR) ** tea._get_duration_array(), + ) + + # Table and NPV property should be consistent. + assert_allclose( + tea.NPV, + table['Cumulative NPV [MM$]'].iloc[-1] * 1e6, + atol=1e-4, + ) + + # Check solve_price with inflation; indirectly checks solve_sales. + price = tea.solve_price(product) + product.price = price + assert_allclose(tea.NPV, 0, atol=100) + def test_add_replacement_cost(): cashflow_array = np.zeros(12) @@ -358,5 +470,6 @@ def test_add_replacement_cost(): test_depreciation_schedule() test_cashflow_consistency() test_tea() + test_tea_with_inflation() test_tea_startup_months() - test_add_replacement_cost() + test_add_replacement_cost() \ No newline at end of file From c4e68e901e4dcef100f83ad7813db0ab7820aa52 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 28 May 2026 13:30:31 +0200 Subject: [PATCH 9/9] Small docstring fixes --- biosteam/_tea.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/biosteam/_tea.py b/biosteam/_tea.py index 9d96575b..4c43d78a 100644 --- a/biosteam/_tea.py +++ b/biosteam/_tea.py @@ -289,7 +289,7 @@ class TEA: IRR : Real internal rate of return (fraction). If `inflation_rate` is given, cashflows are escalated to nominal values and this real IRR is internally - converted to a nominal dicount rate for NPV calculations. This is done by + converted to a nominal discount rate for NPV calculations. This is done by using Fisher equation: nom_IRR = (1 + IRR)*(1 + inflation_rate) - 1. duration : Start and end year of venture (e.g. (2018, 2038)). @@ -322,7 +322,9 @@ class TEA: WC_over_FCI : Working capital as a fraction of fixed capital investment. finance_interest : - Yearly interest of capital cost financing as a fraction. + Yearly interest of capital cost financing as a fraction. If `inflation_rate` + is provided, nominal yearly interest of capiltal cost financing must be + given. finance_years : Number of years the loan is paid for. finance_fraction : @@ -1242,7 +1244,7 @@ def __repr__(self): def _info(self): return (f'{type(self).__name__}: {self.system}\n' - f'NPV: {self.NPV:,.0f} USD at {self.IRR:.1%} IRR') + f'NPV: {self.NPV:,.0f} USD at {self.IRR:.1%} real IRR') def show(self): """Prints information on unit."""