OpenPyTEA 1.2.0__py3-none-any.whl
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.
- openpytea/__init__.py +24 -0
- openpytea/analysis.py +2127 -0
- openpytea/data/cepci_values.csv +36 -0
- openpytea/data/cost_correlations.csv +166 -0
- openpytea/equipment.py +432 -0
- openpytea/plant.py +1821 -0
- openpytea-1.2.0.dist-info/METADATA +300 -0
- openpytea-1.2.0.dist-info/RECORD +11 -0
- openpytea-1.2.0.dist-info/WHEEL +5 -0
- openpytea-1.2.0.dist-info/licenses/LICENSE +21 -0
- openpytea-1.2.0.dist-info/top_level.txt +1 -0
openpytea/plant.py
ADDED
|
@@ -0,0 +1,1821 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import List, Dict, Literal, Optional
|
|
6
|
+
from scipy.optimize import root_scalar
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Plant:
|
|
10
|
+
"""
|
|
11
|
+
Plant cost estimation and financial analysis module.
|
|
12
|
+
This module provides the Plant class for comprehensive TEA
|
|
13
|
+
of industrial process plants, including capital cost estimation,
|
|
14
|
+
operating cost calculation, and financial metrics computation.
|
|
15
|
+
Classes:
|
|
16
|
+
Plant: Main class for plant configuration and financial analysis.
|
|
17
|
+
The Plant class supports:
|
|
18
|
+
- Capital cost estimation (ISBL, OSBL, fixed capital)
|
|
19
|
+
- Operating expense calculation (fixed and variable OPEX)
|
|
20
|
+
- Revenue estimation from multiple products
|
|
21
|
+
- Cash flow analysis with production ramps and depreciation
|
|
22
|
+
- Financial metrics (NPV, IRR, ROI, payback period, levelized cost)
|
|
23
|
+
- Support for Monte Carlo uncertainty analysis
|
|
24
|
+
- Location-based cost factors for multiple countries/regions
|
|
25
|
+
- Flexible equipment and process type configurations
|
|
26
|
+
Typical workflow:
|
|
27
|
+
1. Initialize Plant with configuration dictionary
|
|
28
|
+
2. Add equipment to equipment_list
|
|
29
|
+
3. Configure products, OPEX inputs, and operator rates
|
|
30
|
+
4. Call calculate_cash_flow() for financial analysis
|
|
31
|
+
5. Compute metrics like calculate_npv(), calculate_irr(), etc.
|
|
32
|
+
Attributes:
|
|
33
|
+
processTypes (dict):
|
|
34
|
+
Default process multipliers for Solids, Fluids, Mixed
|
|
35
|
+
locFactors (dict):
|
|
36
|
+
Location-based cost adjustment factors by country/region
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
processTypes = {
|
|
40
|
+
"Solids": {"OS": 0.4, "DE": 0.2, "X": 0.1},
|
|
41
|
+
"Fluids": {"OS": 0.3, "DE": 0.3, "X": 0.1},
|
|
42
|
+
"Mixed": {"OS": 0.4, "DE": 0.25, "X": 0.1},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
locFactors = {
|
|
46
|
+
"United States": {
|
|
47
|
+
"Gulf Coast": 1.00,
|
|
48
|
+
"East Coast": 1.04,
|
|
49
|
+
"West Coast": 1.07,
|
|
50
|
+
"Midwest": 1.02,
|
|
51
|
+
},
|
|
52
|
+
"Canada": {"Ontario": 1.00, "Fort McMurray": 1.60},
|
|
53
|
+
"Mexico": 1.03,
|
|
54
|
+
"Brazil": 1.14,
|
|
55
|
+
"China": {"imported": 1.12, "indigenous": 0.61},
|
|
56
|
+
"Japan": 1.26,
|
|
57
|
+
"Southeast Asia": 1.12,
|
|
58
|
+
"Australia": 1.21,
|
|
59
|
+
"India": 1.02,
|
|
60
|
+
"Middle East": 1.07,
|
|
61
|
+
"France": 1.13,
|
|
62
|
+
"Germany": 1.11,
|
|
63
|
+
"Italy": 1.14,
|
|
64
|
+
"Netherlands": 1.19,
|
|
65
|
+
"Russia": 1.53,
|
|
66
|
+
"United Kingdom": 1.02,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def __init__(self, configuration: dict):
|
|
70
|
+
|
|
71
|
+
# keep a copy of the original config so code can read from it later
|
|
72
|
+
self.config = deepcopy(configuration)
|
|
73
|
+
|
|
74
|
+
self.name = configuration.get("plant_name")
|
|
75
|
+
self.process_type = configuration.get(
|
|
76
|
+
"process_type"
|
|
77
|
+
)
|
|
78
|
+
self.country = configuration.get(
|
|
79
|
+
"country", "United States"
|
|
80
|
+
)
|
|
81
|
+
self.region = configuration.get(
|
|
82
|
+
"region", "Gulf Coast"
|
|
83
|
+
)
|
|
84
|
+
self.working_capital = configuration.get(
|
|
85
|
+
"working_capital", None
|
|
86
|
+
)
|
|
87
|
+
self.interest_rate = configuration.get(
|
|
88
|
+
"interest_rate", 0.09
|
|
89
|
+
)
|
|
90
|
+
self.project_lifetime = configuration.get(
|
|
91
|
+
"project_lifetime", 20
|
|
92
|
+
)
|
|
93
|
+
self.plant_utilization = configuration.get(
|
|
94
|
+
"plant_utilization", 1
|
|
95
|
+
)
|
|
96
|
+
self.tax_rate = configuration.get("tax_rate", 0)
|
|
97
|
+
self.depreciation = configuration.get(
|
|
98
|
+
"depreciation", None
|
|
99
|
+
)
|
|
100
|
+
self.operators_per_shift = configuration.get(
|
|
101
|
+
"operators_per_shift", None
|
|
102
|
+
)
|
|
103
|
+
self.operators_hired = configuration.get(
|
|
104
|
+
"operators_hired", None
|
|
105
|
+
)
|
|
106
|
+
self.working_weeks_per_year = configuration.get(
|
|
107
|
+
"working_weeks_per_year", 49
|
|
108
|
+
)
|
|
109
|
+
self.working_shifts_per_week = configuration.get(
|
|
110
|
+
"working_shifts_per_week", 5
|
|
111
|
+
)
|
|
112
|
+
self.operating_shifts_per_day = configuration.get(
|
|
113
|
+
"operating_shifts_per_day", 3
|
|
114
|
+
)
|
|
115
|
+
self.additional_capex_years = configuration.get(
|
|
116
|
+
"additional_capex_years", None
|
|
117
|
+
)
|
|
118
|
+
self.additional_capex_cost = configuration.get(
|
|
119
|
+
"additional_capex_cost", None
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self.equipment_list = configuration.get(
|
|
123
|
+
"equipment", []
|
|
124
|
+
)
|
|
125
|
+
self.operator_hourly_rate = configuration.get(
|
|
126
|
+
"operator_hourly_rate", {}
|
|
127
|
+
)
|
|
128
|
+
self.variable_opex_inputs = configuration.get(
|
|
129
|
+
"variable_opex_inputs", {}
|
|
130
|
+
)
|
|
131
|
+
self.plant_products = configuration.get(
|
|
132
|
+
"plant_products", {}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.fc = None
|
|
136
|
+
self.fp = None
|
|
137
|
+
|
|
138
|
+
self.monte_carlo_inputs = None
|
|
139
|
+
self.monte_carlo_metrics = None
|
|
140
|
+
|
|
141
|
+
def update_configuration(self, configuration: dict):
|
|
142
|
+
|
|
143
|
+
# keep the stored config up to date
|
|
144
|
+
if (
|
|
145
|
+
not hasattr(self, "config")
|
|
146
|
+
or self.config is None
|
|
147
|
+
):
|
|
148
|
+
self.config = {}
|
|
149
|
+
# shallow-merge top-level keys first
|
|
150
|
+
self.config.update(
|
|
151
|
+
{
|
|
152
|
+
k: v
|
|
153
|
+
for k, v in configuration.items()
|
|
154
|
+
if k
|
|
155
|
+
not in [
|
|
156
|
+
"variable_opex_inputs",
|
|
157
|
+
"plant_products",
|
|
158
|
+
"operator_hourly_rate",
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
self.name = configuration.get(
|
|
164
|
+
"plant_name", self.name
|
|
165
|
+
)
|
|
166
|
+
self.process_type = configuration.get(
|
|
167
|
+
"process_type", self.process_type
|
|
168
|
+
)
|
|
169
|
+
self.country = configuration.get(
|
|
170
|
+
"country", self.country
|
|
171
|
+
)
|
|
172
|
+
self.region = configuration.get(
|
|
173
|
+
"region", self.region
|
|
174
|
+
)
|
|
175
|
+
self.equipment_list = configuration.get(
|
|
176
|
+
"equipment", self.equipment_list
|
|
177
|
+
)
|
|
178
|
+
self.working_capital = configuration.get(
|
|
179
|
+
"working_capital", self.working_capital
|
|
180
|
+
)
|
|
181
|
+
self.interest_rate = configuration.get(
|
|
182
|
+
"interest_rate", self.interest_rate
|
|
183
|
+
)
|
|
184
|
+
self.project_lifetime = configuration.get(
|
|
185
|
+
"project_lifetime", self.project_lifetime
|
|
186
|
+
)
|
|
187
|
+
self.plant_utilization = configuration.get(
|
|
188
|
+
"plant_utilization", self.plant_utilization
|
|
189
|
+
)
|
|
190
|
+
self.tax_rate = configuration.get(
|
|
191
|
+
"tax_rate", self.tax_rate
|
|
192
|
+
)
|
|
193
|
+
self.operators_per_shift = configuration.get(
|
|
194
|
+
"operators_per_shift", self.operators_per_shift
|
|
195
|
+
)
|
|
196
|
+
self.operators_hired = configuration.get(
|
|
197
|
+
"operators_hired", self.operators_hired
|
|
198
|
+
)
|
|
199
|
+
self.working_weeks_per_year = configuration.get(
|
|
200
|
+
"working_weeks_per_year",
|
|
201
|
+
self.working_weeks_per_year,
|
|
202
|
+
)
|
|
203
|
+
self.working_shifts_per_week = configuration.get(
|
|
204
|
+
"working_shifts_per_week",
|
|
205
|
+
self.working_shifts_per_week,
|
|
206
|
+
)
|
|
207
|
+
self.operating_shifts_per_day = configuration.get(
|
|
208
|
+
"operating_shifts_per_day",
|
|
209
|
+
self.operating_shifts_per_day,
|
|
210
|
+
)
|
|
211
|
+
self.additional_capex_years = configuration.get(
|
|
212
|
+
"additional_capex_years",
|
|
213
|
+
self.additional_capex_years,
|
|
214
|
+
)
|
|
215
|
+
self.additional_capex_cost = configuration.get(
|
|
216
|
+
"additional_capex_cost",
|
|
217
|
+
self.additional_capex_cost,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# NEW: allow updating depreciation block
|
|
221
|
+
if "depreciation" in configuration:
|
|
222
|
+
self.depreciation = configuration[
|
|
223
|
+
"depreciation"
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
# merge nested variable_opex_inputs without clobbering
|
|
227
|
+
def recursive_update(original, updates):
|
|
228
|
+
for key, value in updates.items():
|
|
229
|
+
if isinstance(value, dict) and isinstance(
|
|
230
|
+
original.get(key), dict
|
|
231
|
+
):
|
|
232
|
+
recursive_update(original[key], value)
|
|
233
|
+
else:
|
|
234
|
+
original[key] = value
|
|
235
|
+
|
|
236
|
+
if "variable_opex_inputs" in configuration:
|
|
237
|
+
if (
|
|
238
|
+
not hasattr(self, "variable_opex_inputs")
|
|
239
|
+
or self.variable_opex_inputs is None
|
|
240
|
+
):
|
|
241
|
+
self.variable_opex_inputs = {}
|
|
242
|
+
recursive_update(
|
|
243
|
+
self.variable_opex_inputs,
|
|
244
|
+
configuration["variable_opex_inputs"],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# also mirror into stored config
|
|
248
|
+
if "variable_opex_inputs" not in self.config:
|
|
249
|
+
self.config["variable_opex_inputs"] = {}
|
|
250
|
+
recursive_update(
|
|
251
|
+
self.config["variable_opex_inputs"],
|
|
252
|
+
configuration["variable_opex_inputs"],
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if "plant_products" in configuration:
|
|
256
|
+
if (
|
|
257
|
+
not hasattr(self, "plant_products")
|
|
258
|
+
or self.plant_products is None
|
|
259
|
+
):
|
|
260
|
+
self.plant_products = {}
|
|
261
|
+
recursive_update(
|
|
262
|
+
self.plant_products,
|
|
263
|
+
configuration["plant_products"],
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# also mirror into stored config
|
|
267
|
+
if "plant_products" not in self.config:
|
|
268
|
+
self.config["plant_products"] = {}
|
|
269
|
+
recursive_update(
|
|
270
|
+
self.config["plant_products"],
|
|
271
|
+
configuration["plant_products"],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if "operator_hourly_rate" in configuration:
|
|
275
|
+
if (
|
|
276
|
+
not hasattr(self, "operator_hourly_rate")
|
|
277
|
+
or self.operator_hourly_rate is None
|
|
278
|
+
):
|
|
279
|
+
self.operator_hourly_rate = {}
|
|
280
|
+
recursive_update(
|
|
281
|
+
self.operator_hourly_rate,
|
|
282
|
+
configuration["operator_hourly_rate"],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# also mirror into stored config
|
|
286
|
+
if "operator_hourly_rate" not in self.config:
|
|
287
|
+
self.config["operator_hourly_rate"] = {}
|
|
288
|
+
recursive_update(
|
|
289
|
+
self.config["operator_hourly_rate"],
|
|
290
|
+
configuration["operator_hourly_rate"],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def calculate_purchased_cost(self, print_results=False):
|
|
294
|
+
|
|
295
|
+
self.purchased_cost = sum(
|
|
296
|
+
equipment.purchased_cost
|
|
297
|
+
for equipment in self.equipment_list
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if print_results:
|
|
301
|
+
# Print the results
|
|
302
|
+
print("Purchased cost estimation")
|
|
303
|
+
print("===================================")
|
|
304
|
+
for equipment in self.equipment_list:
|
|
305
|
+
print(
|
|
306
|
+
f" - {equipment.name}: ${equipment.purchased_cost:,.2f}"
|
|
307
|
+
)
|
|
308
|
+
print("===================================")
|
|
309
|
+
print(
|
|
310
|
+
f"Total Purchased Cost: ${self.purchased_cost:,.2f}"
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
return self.purchased_cost
|
|
314
|
+
|
|
315
|
+
def calculate_isbl(self, fc=1.0, print_results=False):
|
|
316
|
+
|
|
317
|
+
def location_factors() -> float:
|
|
318
|
+
|
|
319
|
+
if self.country not in self.locFactors:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"Country not found: {self.country}. "
|
|
322
|
+
f"Available countries: {list(self.locFactors.keys())}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
loc_factor = self.locFactors[self.country]
|
|
326
|
+
if isinstance(loc_factor, dict):
|
|
327
|
+
if self.region in loc_factor:
|
|
328
|
+
return loc_factor[self.region]
|
|
329
|
+
else:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"Region not found: {self.region}. "
|
|
332
|
+
f"Available regions: {list(loc_factor.keys())}"
|
|
333
|
+
)
|
|
334
|
+
return loc_factor
|
|
335
|
+
|
|
336
|
+
self.isbl = (
|
|
337
|
+
sum(
|
|
338
|
+
equipment.direct_cost
|
|
339
|
+
for equipment in self.equipment_list
|
|
340
|
+
)
|
|
341
|
+
* location_factors()
|
|
342
|
+
* fc
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if print_results:
|
|
346
|
+
# Print the resultS
|
|
347
|
+
print("ISBL cost estimation")
|
|
348
|
+
print("===================================")
|
|
349
|
+
for equipment in self.equipment_list:
|
|
350
|
+
print(
|
|
351
|
+
f" - {equipment.name}: ${equipment.direct_cost:,.2f}"
|
|
352
|
+
)
|
|
353
|
+
print("===================================")
|
|
354
|
+
print(f"Total ISBL: ${self.isbl:,.2f}")
|
|
355
|
+
else:
|
|
356
|
+
return self.isbl
|
|
357
|
+
|
|
358
|
+
def calculate_fixed_capital(
|
|
359
|
+
self,
|
|
360
|
+
fc=None,
|
|
361
|
+
additional_capex: bool = False,
|
|
362
|
+
print_results=False,
|
|
363
|
+
):
|
|
364
|
+
|
|
365
|
+
if fc is None:
|
|
366
|
+
self.fc = 1.0
|
|
367
|
+
else:
|
|
368
|
+
self.fc = fc
|
|
369
|
+
self.calculate_isbl(self.fc)
|
|
370
|
+
|
|
371
|
+
if self.process_type not in self.processTypes:
|
|
372
|
+
raise ValueError(
|
|
373
|
+
f"Unsupported process_type '{self.process_type}'. "
|
|
374
|
+
f"Valid types: {list(self.processTypes)}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
params = self.processTypes[self.process_type]
|
|
378
|
+
self.osbl = params["OS"] * self.isbl
|
|
379
|
+
self.dne = params["DE"] * (self.isbl + self.osbl)
|
|
380
|
+
self.contigency = params["X"] * (
|
|
381
|
+
self.isbl + self.osbl
|
|
382
|
+
)
|
|
383
|
+
self.fixed_capital = (
|
|
384
|
+
self.isbl
|
|
385
|
+
+ self.osbl
|
|
386
|
+
+ self.dne
|
|
387
|
+
+ self.contigency
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if print_results:
|
|
391
|
+
if (
|
|
392
|
+
additional_capex
|
|
393
|
+
and self.additional_capex_cost is not None
|
|
394
|
+
):
|
|
395
|
+
# Print the results
|
|
396
|
+
print("Capital cost estimation")
|
|
397
|
+
print("===================================")
|
|
398
|
+
print(f"ISBL: ${self.isbl:,.2f}")
|
|
399
|
+
print(f"OSBL: ${self.osbl:,.2f}")
|
|
400
|
+
print(
|
|
401
|
+
f"Design and engineering: ${self.dne:,.2f}"
|
|
402
|
+
)
|
|
403
|
+
print(
|
|
404
|
+
f"Contingency: ${self.contigency:,.2f}"
|
|
405
|
+
)
|
|
406
|
+
print(
|
|
407
|
+
f"Additional CAPEX: "
|
|
408
|
+
f"${sum(self.additional_capex_cost):,.2f}"
|
|
409
|
+
)
|
|
410
|
+
print("===================================")
|
|
411
|
+
total_capex = (
|
|
412
|
+
self.fixed_capital
|
|
413
|
+
+ sum(self.additional_capex_cost)
|
|
414
|
+
)
|
|
415
|
+
print(
|
|
416
|
+
f"Fixed capital investment: "
|
|
417
|
+
f"${total_capex:,.2f}"
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
# Print the results
|
|
421
|
+
print("Capital cost estimation")
|
|
422
|
+
print("===================================")
|
|
423
|
+
print(f"ISBL: ${self.isbl:,.2f}")
|
|
424
|
+
print(f"OSBL: ${self.osbl:,.2f}")
|
|
425
|
+
print(
|
|
426
|
+
f"Design and engineering: ${self.dne:,.2f}"
|
|
427
|
+
)
|
|
428
|
+
print(
|
|
429
|
+
f"Contingency: ${self.contigency:,.2f}"
|
|
430
|
+
)
|
|
431
|
+
print("===================================")
|
|
432
|
+
print(
|
|
433
|
+
f"Fixed capital investment: ${self.fixed_capital:,.2f}"
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
return self.fixed_capital
|
|
437
|
+
|
|
438
|
+
def calculate_variable_opex(self, print_results=False):
|
|
439
|
+
self.variable_production_costs = 0
|
|
440
|
+
self.variable_opex_breakdown = {}
|
|
441
|
+
|
|
442
|
+
for (
|
|
443
|
+
item,
|
|
444
|
+
details,
|
|
445
|
+
) in self.variable_opex_inputs.items():
|
|
446
|
+
consumption = details.get("consumption", 0)
|
|
447
|
+
price = details.get("price", 0)
|
|
448
|
+
|
|
449
|
+
cost = (
|
|
450
|
+
consumption
|
|
451
|
+
* price
|
|
452
|
+
* 365
|
|
453
|
+
* self.plant_utilization
|
|
454
|
+
)
|
|
455
|
+
self.variable_opex_breakdown[item] = cost
|
|
456
|
+
self.variable_production_costs += cost
|
|
457
|
+
|
|
458
|
+
if print_results:
|
|
459
|
+
print("Variable production costs estimation")
|
|
460
|
+
print("===================================")
|
|
461
|
+
for (
|
|
462
|
+
item,
|
|
463
|
+
cost,
|
|
464
|
+
) in self.variable_opex_breakdown.items():
|
|
465
|
+
item_name = item.replace(
|
|
466
|
+
"_", " "
|
|
467
|
+
).capitalize()
|
|
468
|
+
print(
|
|
469
|
+
f" - {item_name}: ${cost:,.2f} per year"
|
|
470
|
+
)
|
|
471
|
+
print("===================================")
|
|
472
|
+
print(
|
|
473
|
+
f"Total Variable OPEX: "
|
|
474
|
+
f"${self.variable_production_costs:,.2f} per year"
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
return self.variable_production_costs
|
|
478
|
+
|
|
479
|
+
def calculate_revenue(self, print_results=False):
|
|
480
|
+
self.revenue = 0
|
|
481
|
+
self.revenue_breakdown = {}
|
|
482
|
+
|
|
483
|
+
self.main_product = (
|
|
484
|
+
next(iter(self.plant_products))
|
|
485
|
+
if self.plant_products
|
|
486
|
+
else None
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
for product, details in self.plant_products.items():
|
|
490
|
+
production = details.get("production", 0)
|
|
491
|
+
price = details.get("price", 0)
|
|
492
|
+
|
|
493
|
+
revenue = (
|
|
494
|
+
production
|
|
495
|
+
* price
|
|
496
|
+
* 365
|
|
497
|
+
* self.plant_utilization
|
|
498
|
+
)
|
|
499
|
+
self.revenue_breakdown[product] = revenue
|
|
500
|
+
self.revenue += revenue
|
|
501
|
+
|
|
502
|
+
if print_results:
|
|
503
|
+
print("Revenue estimation")
|
|
504
|
+
print("===================================")
|
|
505
|
+
for (
|
|
506
|
+
product,
|
|
507
|
+
revenue,
|
|
508
|
+
) in self.revenue_breakdown.items():
|
|
509
|
+
product_name = product.replace(
|
|
510
|
+
"_", " "
|
|
511
|
+
).capitalize()
|
|
512
|
+
print(
|
|
513
|
+
f" - {product_name}: ${revenue:,.2f} per year"
|
|
514
|
+
)
|
|
515
|
+
print("===================================")
|
|
516
|
+
print(
|
|
517
|
+
f"Total Revenue: ${self.revenue:,.2f} per year"
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
return self.revenue
|
|
521
|
+
|
|
522
|
+
def count_process_steps(
|
|
523
|
+
self,
|
|
524
|
+
equipments,
|
|
525
|
+
target_process_types,
|
|
526
|
+
excluded_cats=None,
|
|
527
|
+
):
|
|
528
|
+
if excluded_cats is None:
|
|
529
|
+
excluded_cats = {}
|
|
530
|
+
count = 0
|
|
531
|
+
for equipment in equipments:
|
|
532
|
+
if (
|
|
533
|
+
equipment.process_type
|
|
534
|
+
in target_process_types
|
|
535
|
+
and equipment.category not in excluded_cats
|
|
536
|
+
):
|
|
537
|
+
count += 1
|
|
538
|
+
return count
|
|
539
|
+
|
|
540
|
+
def calculate_operators_per_shift(
|
|
541
|
+
self, no_fluid_process=None, no_solid_process=None
|
|
542
|
+
):
|
|
543
|
+
if self.operators_per_shift is not None:
|
|
544
|
+
return self.operators_per_shift
|
|
545
|
+
else:
|
|
546
|
+
if no_fluid_process is None:
|
|
547
|
+
no_fluid_process = self.count_process_steps(
|
|
548
|
+
self.equipment_list,
|
|
549
|
+
{"Fluids", "Mixed"},
|
|
550
|
+
{"Pumps", "Pressure vessels"},
|
|
551
|
+
)
|
|
552
|
+
if no_solid_process is None:
|
|
553
|
+
no_solid_process = self.count_process_steps(
|
|
554
|
+
self.equipment_list,
|
|
555
|
+
{"Solids", "Mixed"},
|
|
556
|
+
{"Pumps", "Pressure vessels"},
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
if no_solid_process > 2:
|
|
560
|
+
raise ValueError(
|
|
561
|
+
"Number of solid processes needs "
|
|
562
|
+
"to be less than or equal to 2."
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
operators_per_shifts = (
|
|
566
|
+
6.29
|
|
567
|
+
+ 31.7 * (no_solid_process**2)
|
|
568
|
+
+ 0.23 * no_fluid_process
|
|
569
|
+
) ** 0.5
|
|
570
|
+
return operators_per_shifts
|
|
571
|
+
|
|
572
|
+
def calculate_operators_hired(
|
|
573
|
+
self, no_fluid_process=None, no_solid_process=None
|
|
574
|
+
):
|
|
575
|
+
if self.operators_hired is not None:
|
|
576
|
+
return self.operators_hired
|
|
577
|
+
|
|
578
|
+
else:
|
|
579
|
+
operators_per_shifts = (
|
|
580
|
+
self.calculate_operators_per_shift(
|
|
581
|
+
no_fluid_process, no_solid_process
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
operating_shifts_per_year = (
|
|
586
|
+
365 * self.operating_shifts_per_day
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
working_shifts_per_year = (
|
|
590
|
+
self.working_weeks_per_year
|
|
591
|
+
* self.working_shifts_per_week
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
operators_hired = math.ceil(
|
|
595
|
+
operators_per_shifts
|
|
596
|
+
* operating_shifts_per_year
|
|
597
|
+
/ working_shifts_per_year
|
|
598
|
+
)
|
|
599
|
+
return operators_hired
|
|
600
|
+
|
|
601
|
+
def calculate_operating_labor(
|
|
602
|
+
self, no_fluid_process=None, no_solid_process=None
|
|
603
|
+
):
|
|
604
|
+
operators_hired = self.calculate_operators_hired(
|
|
605
|
+
no_fluid_process, no_solid_process
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
working_shifts_per_year = (
|
|
609
|
+
self.working_weeks_per_year
|
|
610
|
+
* self.working_shifts_per_week
|
|
611
|
+
)
|
|
612
|
+
working_hours_per_year = working_shifts_per_year * (
|
|
613
|
+
24 / self.operating_shifts_per_day
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
rate_cfg = self.operator_hourly_rate
|
|
617
|
+
if isinstance(rate_cfg, dict):
|
|
618
|
+
rate = rate_cfg.get("rate", 38.11)
|
|
619
|
+
else:
|
|
620
|
+
rate = (
|
|
621
|
+
38.11
|
|
622
|
+
if rate_cfg is None
|
|
623
|
+
else float(rate_cfg)
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
self.operating_labor_costs = (
|
|
627
|
+
operators_hired * working_hours_per_year * rate
|
|
628
|
+
)
|
|
629
|
+
return self.operating_labor_costs
|
|
630
|
+
|
|
631
|
+
def calculate_fixed_opex(
|
|
632
|
+
self, fp=None, print_results=False
|
|
633
|
+
):
|
|
634
|
+
if fp is None:
|
|
635
|
+
self.fp = 1.0
|
|
636
|
+
else:
|
|
637
|
+
self.fp = fp
|
|
638
|
+
|
|
639
|
+
self.calculate_fixed_capital(fc=self.fc)
|
|
640
|
+
self.calculate_variable_opex()
|
|
641
|
+
self.calculate_operating_labor()
|
|
642
|
+
self.supervision_costs = (
|
|
643
|
+
0.25 * self.operating_labor_costs
|
|
644
|
+
)
|
|
645
|
+
self.direct_salary_overhead = 0.5 * (
|
|
646
|
+
self.operating_labor_costs
|
|
647
|
+
+ self.supervision_costs
|
|
648
|
+
)
|
|
649
|
+
self.laboratory_charges = (
|
|
650
|
+
0.10 * self.operating_labor_costs
|
|
651
|
+
)
|
|
652
|
+
self.maintenance_costs = 0.05 * self.isbl
|
|
653
|
+
self.taxes_insurance_costs = 0.015 * self.isbl
|
|
654
|
+
self.rent_of_land_costs = 0.015 * (
|
|
655
|
+
self.isbl + self.osbl
|
|
656
|
+
)
|
|
657
|
+
self.environmental_charges = 0.01 * (
|
|
658
|
+
self.isbl + self.osbl
|
|
659
|
+
)
|
|
660
|
+
self.operating_supplies = 0.009 * self.isbl
|
|
661
|
+
self.general_plant_overhead = 0.65 * (
|
|
662
|
+
self.operating_labor_costs
|
|
663
|
+
+ self.supervision_costs
|
|
664
|
+
+ self.direct_salary_overhead
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
if self.working_capital is not None:
|
|
668
|
+
self.interest_working_capital = (
|
|
669
|
+
self.working_capital * self.interest_rate
|
|
670
|
+
)
|
|
671
|
+
else:
|
|
672
|
+
self.working_capital = 0.15 * self.fixed_capital
|
|
673
|
+
self.interest_working_capital = (
|
|
674
|
+
self.working_capital * self.interest_rate
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
self.fixed_production_costs = (
|
|
678
|
+
self.operating_labor_costs
|
|
679
|
+
+ self.supervision_costs
|
|
680
|
+
+ self.direct_salary_overhead
|
|
681
|
+
+ self.laboratory_charges
|
|
682
|
+
+ self.maintenance_costs
|
|
683
|
+
+ self.taxes_insurance_costs
|
|
684
|
+
+ self.rent_of_land_costs
|
|
685
|
+
+ self.environmental_charges
|
|
686
|
+
+ self.operating_supplies
|
|
687
|
+
+ self.general_plant_overhead
|
|
688
|
+
+ self.interest_working_capital
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
cash_cost_of_production = (
|
|
692
|
+
self.variable_production_costs
|
|
693
|
+
+ self.fixed_production_costs
|
|
694
|
+
) / (1 - 0.07)
|
|
695
|
+
|
|
696
|
+
self.patents_royalties = (
|
|
697
|
+
0.02 * cash_cost_of_production
|
|
698
|
+
)
|
|
699
|
+
self.distribution_selling_costs = (
|
|
700
|
+
0.02 * cash_cost_of_production
|
|
701
|
+
)
|
|
702
|
+
self.RnD_costs = 0.03 * cash_cost_of_production
|
|
703
|
+
|
|
704
|
+
self.fixed_production_costs += (
|
|
705
|
+
self.patents_royalties
|
|
706
|
+
+ self.distribution_selling_costs
|
|
707
|
+
+ self.RnD_costs
|
|
708
|
+
)
|
|
709
|
+
self.fixed_production_costs *= self.fp
|
|
710
|
+
|
|
711
|
+
if print_results:
|
|
712
|
+
# Print the results
|
|
713
|
+
print("Fixed production costs estimation")
|
|
714
|
+
print("===================================")
|
|
715
|
+
print(
|
|
716
|
+
f"Operating labor costs: "
|
|
717
|
+
f"${self.operating_labor_costs:,.2f} per year"
|
|
718
|
+
)
|
|
719
|
+
print(
|
|
720
|
+
f"Supervision costs: "
|
|
721
|
+
f"${self.supervision_costs:,.2f} per year"
|
|
722
|
+
)
|
|
723
|
+
print(
|
|
724
|
+
f"Direct salary overhead: "
|
|
725
|
+
f"${self.direct_salary_overhead:,.2f} per year"
|
|
726
|
+
)
|
|
727
|
+
print(
|
|
728
|
+
f"Laboratory charges: "
|
|
729
|
+
f"${self.laboratory_charges:,.2f} per year"
|
|
730
|
+
)
|
|
731
|
+
print(
|
|
732
|
+
f"Maintenance costs: "
|
|
733
|
+
f"${self.maintenance_costs:,.2f} per year"
|
|
734
|
+
)
|
|
735
|
+
print(
|
|
736
|
+
f"Taxes and insurance costs: "
|
|
737
|
+
f"${self.taxes_insurance_costs:,.2f} per year"
|
|
738
|
+
)
|
|
739
|
+
print(
|
|
740
|
+
f"Rent of land costs: "
|
|
741
|
+
f"${self.rent_of_land_costs:,.2f} per year"
|
|
742
|
+
)
|
|
743
|
+
print(
|
|
744
|
+
f"Environmental charges: "
|
|
745
|
+
f"${self.environmental_charges:,.2f} per year"
|
|
746
|
+
)
|
|
747
|
+
print(
|
|
748
|
+
f"Operating supplies: "
|
|
749
|
+
f"${self.operating_supplies:,.2f} per year"
|
|
750
|
+
)
|
|
751
|
+
print(
|
|
752
|
+
f"General plant overhead: "
|
|
753
|
+
f"${self.general_plant_overhead:,.2f} per year"
|
|
754
|
+
)
|
|
755
|
+
print(
|
|
756
|
+
f"Interest on working capital: "
|
|
757
|
+
f"${self.interest_working_capital:,.2f} per year"
|
|
758
|
+
)
|
|
759
|
+
print(
|
|
760
|
+
f"Patents and royalties: "
|
|
761
|
+
f"${self.patents_royalties:,.2f} per year"
|
|
762
|
+
)
|
|
763
|
+
print(
|
|
764
|
+
f"Distribution and selling costs: "
|
|
765
|
+
f"${self.distribution_selling_costs:,.2f} per year"
|
|
766
|
+
)
|
|
767
|
+
print(
|
|
768
|
+
f"R&D costs: ${self.RnD_costs:,.2f} per year"
|
|
769
|
+
)
|
|
770
|
+
print("===================================")
|
|
771
|
+
print(
|
|
772
|
+
f"Fixed OPEX: ${self.fixed_production_costs:,.2f} per year"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
else:
|
|
776
|
+
return self.fixed_production_costs
|
|
777
|
+
|
|
778
|
+
def calculate_cash_flow(
|
|
779
|
+
self, print_results: bool = False
|
|
780
|
+
):
|
|
781
|
+
|
|
782
|
+
# 0) Upstream calcs (capital, opex breakdowns)
|
|
783
|
+
self.calculate_fixed_capital(fc=self.fc)
|
|
784
|
+
self.calculate_variable_opex()
|
|
785
|
+
self.calculate_fixed_opex(fp=self.fp)
|
|
786
|
+
self.calculate_revenue()
|
|
787
|
+
|
|
788
|
+
# --- Normalize shapes ---
|
|
789
|
+
lifetime = np.atleast_1d(
|
|
790
|
+
self.project_lifetime
|
|
791
|
+
).astype(int)
|
|
792
|
+
if np.any(lifetime < 3):
|
|
793
|
+
raise ValueError(
|
|
794
|
+
"All project_lifetime values must be ≥3."
|
|
795
|
+
)
|
|
796
|
+
n_samples = lifetime.shape[0]
|
|
797
|
+
n_years = np.max(lifetime)
|
|
798
|
+
|
|
799
|
+
fixed_capital = np.atleast_1d(
|
|
800
|
+
self.fixed_capital
|
|
801
|
+
).astype(float)
|
|
802
|
+
fixed_opex = np.atleast_1d(
|
|
803
|
+
self.fixed_production_costs
|
|
804
|
+
).astype(float)
|
|
805
|
+
var_opex = np.atleast_1d(
|
|
806
|
+
self.variable_production_costs
|
|
807
|
+
).astype(float)
|
|
808
|
+
interest = np.atleast_1d(self.interest_rate).astype(
|
|
809
|
+
float
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
# Broadcast all scalars to same length
|
|
813
|
+
def broadcast(x):
|
|
814
|
+
return np.broadcast_to(x, n_samples)
|
|
815
|
+
|
|
816
|
+
fixed_capital, fixed_opex, var_opex, interest = map(
|
|
817
|
+
broadcast,
|
|
818
|
+
(fixed_capital, fixed_opex, var_opex, interest),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
# --- Initialize result arrays ---
|
|
822
|
+
shape = (n_samples, n_years)
|
|
823
|
+
capex = np.zeros(shape)
|
|
824
|
+
main_revenue = np.zeros(shape)
|
|
825
|
+
side_revenue = np.zeros(shape)
|
|
826
|
+
revenue = np.zeros(shape)
|
|
827
|
+
cash_cost = np.zeros(shape)
|
|
828
|
+
gross_profit = np.zeros(shape)
|
|
829
|
+
depreciation = np.zeros(shape)
|
|
830
|
+
taxable_income = np.zeros(shape)
|
|
831
|
+
tax_paid = np.zeros(shape)
|
|
832
|
+
cash_flow = np.zeros(shape)
|
|
833
|
+
prod_array = np.zeros(shape)
|
|
834
|
+
|
|
835
|
+
# --- CAPEX profile (30/60/10) + WC draw/release ---
|
|
836
|
+
for yr, frac in zip([0, 1, 2], [0.3, 0.6, 0.1]):
|
|
837
|
+
if yr < n_years:
|
|
838
|
+
capex[:, yr] += fixed_capital * frac
|
|
839
|
+
if 2 < n_years:
|
|
840
|
+
capex[:, 2] += self.working_capital
|
|
841
|
+
capex[:, -1] -= self.working_capital
|
|
842
|
+
|
|
843
|
+
# --- Add additional CAPEX at specified years ---
|
|
844
|
+
if (
|
|
845
|
+
self.additional_capex_years is not None
|
|
846
|
+
and self.additional_capex_cost is not None
|
|
847
|
+
):
|
|
848
|
+
additional_capex_years = np.atleast_1d(
|
|
849
|
+
self.additional_capex_years
|
|
850
|
+
).astype(int)
|
|
851
|
+
additional_capex_cost = np.atleast_1d(
|
|
852
|
+
self.additional_capex_cost
|
|
853
|
+
).astype(float)
|
|
854
|
+
|
|
855
|
+
# Check if the number of years matches the number of costs
|
|
856
|
+
if (
|
|
857
|
+
additional_capex_years.shape[0]
|
|
858
|
+
!= additional_capex_cost.shape[0]
|
|
859
|
+
):
|
|
860
|
+
raise ValueError(
|
|
861
|
+
"The number of additional_capex_years must "
|
|
862
|
+
"match the number of additional_capex_costs."
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
for i, year in enumerate(
|
|
866
|
+
additional_capex_years
|
|
867
|
+
):
|
|
868
|
+
# Ignore invalid years
|
|
869
|
+
if year < 1 or year > n_years:
|
|
870
|
+
continue
|
|
871
|
+
|
|
872
|
+
# Apply only to samples whose lifetime includes this year
|
|
873
|
+
alive_mask = lifetime >= year
|
|
874
|
+
|
|
875
|
+
# Arrays are 0-indexed; NumPy will broadcast the scalar cost
|
|
876
|
+
capex[
|
|
877
|
+
alive_mask, year - 1
|
|
878
|
+
] += additional_capex_cost[i]
|
|
879
|
+
|
|
880
|
+
# --- Production ramp ---
|
|
881
|
+
if (
|
|
882
|
+
not self.plant_products
|
|
883
|
+
or self.main_product is None
|
|
884
|
+
):
|
|
885
|
+
raise ValueError(
|
|
886
|
+
"No plant_products defined; "
|
|
887
|
+
"cannot build cash flow / production profile."
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
self.daily_prod = self.plant_products[
|
|
891
|
+
self.main_product
|
|
892
|
+
]["production"]
|
|
893
|
+
nameplate = (
|
|
894
|
+
self.daily_prod * 365.0 * self.plant_utilization
|
|
895
|
+
)
|
|
896
|
+
ramp = np.concatenate(
|
|
897
|
+
([0, 0, 0.4, 0.8], np.ones(max(0, n_years - 4)))
|
|
898
|
+
)
|
|
899
|
+
ramp = ramp[:n_years]
|
|
900
|
+
|
|
901
|
+
# --- Revenue & cost arrays ---
|
|
902
|
+
for yr in range(n_years):
|
|
903
|
+
prod = nameplate * ramp[yr]
|
|
904
|
+
prod_array[:, yr] = prod
|
|
905
|
+
main_prod_price = self.plant_products[
|
|
906
|
+
self.main_product
|
|
907
|
+
].get("price")
|
|
908
|
+
if main_prod_price is None:
|
|
909
|
+
main_revenue[:, yr] = 0
|
|
910
|
+
else:
|
|
911
|
+
main_revenue[:, yr] = prod * main_prod_price
|
|
912
|
+
side_revenue[:, yr] = sum(
|
|
913
|
+
self.plant_products[p]["production"]
|
|
914
|
+
* 365.0
|
|
915
|
+
* self.plant_utilization
|
|
916
|
+
* ramp[yr]
|
|
917
|
+
* self.plant_products[p].get("price", 0)
|
|
918
|
+
for p in self.plant_products
|
|
919
|
+
if p != self.main_product
|
|
920
|
+
)
|
|
921
|
+
revenue[:, yr] = (
|
|
922
|
+
main_revenue[:, yr] + side_revenue[:, yr]
|
|
923
|
+
)
|
|
924
|
+
cash_cost[:, yr] = (
|
|
925
|
+
fixed_opex + var_opex * ramp[yr]
|
|
926
|
+
)
|
|
927
|
+
gross_profit[:, yr] = (
|
|
928
|
+
revenue[:, yr] - cash_cost[:, yr]
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
# --- Depreciation (each sample has its own config) ---
|
|
932
|
+
dep_cfg = getattr(self, "depreciation", None)
|
|
933
|
+
for i in range(n_samples):
|
|
934
|
+
capex_dict = {
|
|
935
|
+
0: 0.3 * fixed_capital[i],
|
|
936
|
+
1: 0.6 * fixed_capital[i],
|
|
937
|
+
2: 0.1 * fixed_capital[i],
|
|
938
|
+
}
|
|
939
|
+
depreciation[i, : lifetime[i]] = (
|
|
940
|
+
build_depreciation_array(
|
|
941
|
+
project_life=lifetime[i],
|
|
942
|
+
capex_by_year=capex_dict,
|
|
943
|
+
dep_cfg=dep_cfg,
|
|
944
|
+
)
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
# --- Tax and cash flow (with 1-year lag) ---
|
|
948
|
+
for yr in range(n_years):
|
|
949
|
+
taxable_income[:, yr] = (
|
|
950
|
+
gross_profit[:, yr] - depreciation[:, yr]
|
|
951
|
+
)
|
|
952
|
+
if yr == 0:
|
|
953
|
+
tax_paid[:, yr] = 0
|
|
954
|
+
else:
|
|
955
|
+
prev = taxable_income[:, yr - 1]
|
|
956
|
+
tax_paid[:, yr] = np.where(
|
|
957
|
+
prev > 0, self.tax_rate * prev, 0
|
|
958
|
+
)
|
|
959
|
+
cash_flow[:, yr] = (
|
|
960
|
+
gross_profit[:, yr]
|
|
961
|
+
- tax_paid[:, yr]
|
|
962
|
+
- capex[:, yr]
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
# --- Save arrays to instance ---
|
|
966
|
+
self.capital_cost_array = capex
|
|
967
|
+
self.side_revenue_array = side_revenue
|
|
968
|
+
self.main_revenue_array = main_revenue
|
|
969
|
+
self.revenue_array = revenue
|
|
970
|
+
self.cash_cost_array = cash_cost
|
|
971
|
+
self.gross_profit_array = gross_profit
|
|
972
|
+
self.depreciation_array = depreciation
|
|
973
|
+
self.taxable_income_array = taxable_income
|
|
974
|
+
self.tax_paid_array = tax_paid
|
|
975
|
+
self.cash_flow = cash_flow
|
|
976
|
+
self.prod_array = prod_array
|
|
977
|
+
|
|
978
|
+
# --- Optional: return formatted summary if scalar case ---
|
|
979
|
+
if print_results and n_samples == 1:
|
|
980
|
+
years = np.arange(1, n_years + 1)
|
|
981
|
+
data = {
|
|
982
|
+
"Year": years,
|
|
983
|
+
"Capital cost": capex[0],
|
|
984
|
+
"Revenue": revenue[0],
|
|
985
|
+
"Cash cost": cash_cost[0],
|
|
986
|
+
"Gross profit": gross_profit[0],
|
|
987
|
+
"Depreciation": depreciation[0],
|
|
988
|
+
"Taxable income": taxable_income[0],
|
|
989
|
+
"Tax paid": tax_paid[0],
|
|
990
|
+
"Cash flow": cash_flow[0],
|
|
991
|
+
}
|
|
992
|
+
df = pd.DataFrame(data)
|
|
993
|
+
fmt = {
|
|
994
|
+
c: "${:,.2f}"
|
|
995
|
+
for c in df.columns
|
|
996
|
+
if c not in ["Year"]
|
|
997
|
+
}
|
|
998
|
+
return df.style.format(fmt)
|
|
999
|
+
|
|
1000
|
+
def calculate_npv(self, print_results: bool = False):
|
|
1001
|
+
# Ensure 2D cash_flow: [n_scenarios, n_years]
|
|
1002
|
+
cf = np.asarray(self.cash_flow, dtype=float)
|
|
1003
|
+
if cf.ndim == 1:
|
|
1004
|
+
cf = cf[None, :] # [1, n_years]
|
|
1005
|
+
n_scenarios, n_years = cf.shape
|
|
1006
|
+
|
|
1007
|
+
years = np.arange(1, n_years + 1, dtype=float)
|
|
1008
|
+
|
|
1009
|
+
# Interest rate: scalar or per-scenario
|
|
1010
|
+
r = np.atleast_1d(self.interest_rate).astype(float)
|
|
1011
|
+
if r.size == 1:
|
|
1012
|
+
# Same rate for all scenarios
|
|
1013
|
+
discount_factors = (
|
|
1014
|
+
1.0 + r[0]
|
|
1015
|
+
) ** years # [n_years]
|
|
1016
|
+
else:
|
|
1017
|
+
if r.size != n_scenarios:
|
|
1018
|
+
raise ValueError(
|
|
1019
|
+
"interest_rate must be scalar or have length equal to "
|
|
1020
|
+
"the number of scenarios in cash_flow."
|
|
1021
|
+
)
|
|
1022
|
+
# Per-scenario rates
|
|
1023
|
+
discount_factors = (1.0 + r)[:, None] ** years[
|
|
1024
|
+
None, :
|
|
1025
|
+
] # [n_scenarios, n_years]
|
|
1026
|
+
|
|
1027
|
+
# Broadcast division: cf / discount_factors
|
|
1028
|
+
pv_array = cf / discount_factors
|
|
1029
|
+
npv_array = np.cumsum(pv_array, axis=-1)
|
|
1030
|
+
|
|
1031
|
+
self.pv_array = (
|
|
1032
|
+
pv_array # shape [n_scenarios, n_years]
|
|
1033
|
+
)
|
|
1034
|
+
self.npv_array = (
|
|
1035
|
+
npv_array # shape [n_scenarios, n_years]
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
if print_results:
|
|
1039
|
+
print(
|
|
1040
|
+
"Year | Present Value (PV) | Cumulative NPV"
|
|
1041
|
+
)
|
|
1042
|
+
print(
|
|
1043
|
+
"-------------------------------------------"
|
|
1044
|
+
)
|
|
1045
|
+
pv_to_print = pv_array[0]
|
|
1046
|
+
npv_to_print = npv_array[0]
|
|
1047
|
+
for year, pv, npv in zip(
|
|
1048
|
+
range(1, n_years + 1),
|
|
1049
|
+
pv_to_print,
|
|
1050
|
+
npv_to_print,
|
|
1051
|
+
):
|
|
1052
|
+
print(
|
|
1053
|
+
f"{year:4d} | ${float(pv):15,.2f} | ${float(npv):15,.2f}"
|
|
1054
|
+
)
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
# Final-year NPV per scenario
|
|
1058
|
+
final_npv = npv_array[:, -1]
|
|
1059
|
+
if final_npv.size == 1:
|
|
1060
|
+
return float(final_npv[0])
|
|
1061
|
+
return final_npv
|
|
1062
|
+
|
|
1063
|
+
def calculate_levelized_cost(self, print_results=False):
|
|
1064
|
+
self.calculate_fixed_capital(
|
|
1065
|
+
fc=1.0 if self.fc is None else self.fc
|
|
1066
|
+
)
|
|
1067
|
+
self.calculate_variable_opex()
|
|
1068
|
+
self.calculate_fixed_opex(
|
|
1069
|
+
fp=1.0 if self.fp is None else self.fp
|
|
1070
|
+
)
|
|
1071
|
+
self.calculate_revenue()
|
|
1072
|
+
self.calculate_cash_flow()
|
|
1073
|
+
|
|
1074
|
+
n_components = (
|
|
1075
|
+
len(self.project_lifetime)
|
|
1076
|
+
if isinstance(
|
|
1077
|
+
self.project_lifetime, (list, np.ndarray)
|
|
1078
|
+
)
|
|
1079
|
+
else int(self.project_lifetime)
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
capital_cost, prod, cash_cost, side_rev = (
|
|
1083
|
+
self.capital_cost_array,
|
|
1084
|
+
self.prod_array,
|
|
1085
|
+
self.cash_cost_array,
|
|
1086
|
+
self.side_revenue_array,
|
|
1087
|
+
)
|
|
1088
|
+
(
|
|
1089
|
+
disc_capex,
|
|
1090
|
+
disc_opex,
|
|
1091
|
+
disc_prod,
|
|
1092
|
+
disc_side_rev,
|
|
1093
|
+
) = (
|
|
1094
|
+
np.zeros(n_components),
|
|
1095
|
+
np.zeros(n_components),
|
|
1096
|
+
np.zeros(n_components),
|
|
1097
|
+
np.zeros(n_components),
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
if isinstance(
|
|
1101
|
+
self.project_lifetime, (list, np.ndarray)
|
|
1102
|
+
):
|
|
1103
|
+
for i in range(n_components):
|
|
1104
|
+
for year in range(len(cash_cost[i])):
|
|
1105
|
+
discount_factor = (
|
|
1106
|
+
1 + self.interest_rate[i]
|
|
1107
|
+
) ** (year + 1)
|
|
1108
|
+
disc_capex[year] += (
|
|
1109
|
+
capital_cost[i][year]
|
|
1110
|
+
) / discount_factor
|
|
1111
|
+
disc_opex[year] += (
|
|
1112
|
+
cash_cost[i][year]
|
|
1113
|
+
) / discount_factor
|
|
1114
|
+
disc_side_rev[year] += (
|
|
1115
|
+
side_rev[i][year]
|
|
1116
|
+
) / discount_factor
|
|
1117
|
+
disc_prod[year] += (
|
|
1118
|
+
prod[i][year] / discount_factor
|
|
1119
|
+
)
|
|
1120
|
+
else:
|
|
1121
|
+
for year in range(n_components):
|
|
1122
|
+
disc_capex[year] = (
|
|
1123
|
+
capital_cost[0][year]
|
|
1124
|
+
) / ((1 + self.interest_rate) ** (year + 1))
|
|
1125
|
+
disc_opex[year] = (cash_cost[0][year]) / (
|
|
1126
|
+
(1 + self.interest_rate) ** (year + 1)
|
|
1127
|
+
)
|
|
1128
|
+
disc_side_rev[year] = (
|
|
1129
|
+
side_rev[0][year]
|
|
1130
|
+
) / ((1 + self.interest_rate) ** (year + 1))
|
|
1131
|
+
disc_prod[year] = prod[0][year] / (
|
|
1132
|
+
(1 + self.interest_rate) ** (year + 1)
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
self.levelized_cost = max(
|
|
1136
|
+
np.sum(disc_capex + disc_opex - disc_side_rev)
|
|
1137
|
+
/ np.sum(disc_prod),
|
|
1138
|
+
0,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
if print_results:
|
|
1142
|
+
print(
|
|
1143
|
+
f"Levelized cost: ${self.levelized_cost:,.3f}/unit"
|
|
1144
|
+
)
|
|
1145
|
+
else:
|
|
1146
|
+
return self.levelized_cost
|
|
1147
|
+
|
|
1148
|
+
def calculate_payback_time(
|
|
1149
|
+
self, additional_capex=False, print_results=False
|
|
1150
|
+
):
|
|
1151
|
+
revenue, cash_flow = (
|
|
1152
|
+
self.revenue_array,
|
|
1153
|
+
self.cash_flow,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
revenue_generating_years = cash_flow[revenue > 0]
|
|
1157
|
+
|
|
1158
|
+
if len(revenue_generating_years) == 0:
|
|
1159
|
+
self.payback_time = float("nan")
|
|
1160
|
+
else:
|
|
1161
|
+
if (
|
|
1162
|
+
additional_capex
|
|
1163
|
+
and self.additional_capex_cost is not None
|
|
1164
|
+
):
|
|
1165
|
+
total_fixed_capital = (
|
|
1166
|
+
self.fixed_capital
|
|
1167
|
+
+ sum(self.additional_capex_cost)
|
|
1168
|
+
)
|
|
1169
|
+
else:
|
|
1170
|
+
total_fixed_capital = self.fixed_capital
|
|
1171
|
+
average_annual_cash_flow = np.mean(
|
|
1172
|
+
revenue_generating_years
|
|
1173
|
+
)
|
|
1174
|
+
self.payback_time = (
|
|
1175
|
+
total_fixed_capital
|
|
1176
|
+
/ average_annual_cash_flow
|
|
1177
|
+
if average_annual_cash_flow > 0
|
|
1178
|
+
else float("nan")
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
if print_results:
|
|
1182
|
+
print(
|
|
1183
|
+
f"Payback time: {self.payback_time:.2f} years"
|
|
1184
|
+
)
|
|
1185
|
+
else:
|
|
1186
|
+
return self.payback_time
|
|
1187
|
+
|
|
1188
|
+
def calculate_roi(
|
|
1189
|
+
self, additional_capex=False, print_results=False
|
|
1190
|
+
):
|
|
1191
|
+
net_profit = (
|
|
1192
|
+
self.gross_profit_array - self.tax_paid_array
|
|
1193
|
+
)
|
|
1194
|
+
if (
|
|
1195
|
+
additional_capex
|
|
1196
|
+
and self.additional_capex_cost is not None
|
|
1197
|
+
):
|
|
1198
|
+
total_investment = (
|
|
1199
|
+
self.fixed_capital
|
|
1200
|
+
+ sum(self.additional_capex_cost)
|
|
1201
|
+
+ self.working_capital
|
|
1202
|
+
)
|
|
1203
|
+
else:
|
|
1204
|
+
total_investment = (
|
|
1205
|
+
self.fixed_capital + self.working_capital
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
self.roi = (
|
|
1209
|
+
np.sum(net_profit)
|
|
1210
|
+
* 100
|
|
1211
|
+
/ (
|
|
1212
|
+
self.project_lifetime
|
|
1213
|
+
* np.sum(total_investment)
|
|
1214
|
+
)
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
if print_results:
|
|
1218
|
+
print(f"Return of investment: {self.roi:.2f}%")
|
|
1219
|
+
else:
|
|
1220
|
+
return self.roi
|
|
1221
|
+
|
|
1222
|
+
def calculate_irr(self, print_results: bool = False):
|
|
1223
|
+
cf = np.asarray(self.cash_flow, dtype=float)
|
|
1224
|
+
n = cf.size
|
|
1225
|
+
if n == 0:
|
|
1226
|
+
self.irr = float("nan")
|
|
1227
|
+
if print_results:
|
|
1228
|
+
print(
|
|
1229
|
+
"Internal Rate of Return: undefined (empty cash flow)."
|
|
1230
|
+
)
|
|
1231
|
+
return self.irr
|
|
1232
|
+
|
|
1233
|
+
# Must have at least one negative and one positive cash flow
|
|
1234
|
+
if not (np.any(cf < 0) and np.any(cf > 0)):
|
|
1235
|
+
self.irr = float("nan")
|
|
1236
|
+
if print_results:
|
|
1237
|
+
print(
|
|
1238
|
+
"Internal Rate of Return: undefined "
|
|
1239
|
+
"(no sign change in cash flows)."
|
|
1240
|
+
)
|
|
1241
|
+
return self.irr
|
|
1242
|
+
|
|
1243
|
+
years = np.arange(n, dtype=float) + 1
|
|
1244
|
+
|
|
1245
|
+
def npv_at(r: float) -> float:
|
|
1246
|
+
# r <= -1 is out of domain
|
|
1247
|
+
if r <= -1.0:
|
|
1248
|
+
return np.inf
|
|
1249
|
+
return float(np.sum(cf / (1.0 + r) ** years))
|
|
1250
|
+
|
|
1251
|
+
# 1) Scan for a bracket with a sign change in NPV
|
|
1252
|
+
# (dense near -1, then spread out to high positives)
|
|
1253
|
+
grid = np.concatenate(
|
|
1254
|
+
[
|
|
1255
|
+
np.linspace(
|
|
1256
|
+
-0.95, -0.01, 120, endpoint=True
|
|
1257
|
+
),
|
|
1258
|
+
np.array(
|
|
1259
|
+
[0.0]
|
|
1260
|
+
), # allow exact 0 as a candidate
|
|
1261
|
+
np.linspace(0.01, 10.0, 240, endpoint=True),
|
|
1262
|
+
]
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
npv_vals = np.array([npv_at(r) for r in grid])
|
|
1266
|
+
|
|
1267
|
+
# Find adjacent points where NPV changes sign (ignore infinities)
|
|
1268
|
+
bracket = None
|
|
1269
|
+
for i in range(len(grid) - 1):
|
|
1270
|
+
a, b = grid[i], grid[i + 1]
|
|
1271
|
+
fa, fb = npv_vals[i], npv_vals[i + 1]
|
|
1272
|
+
if not np.isfinite(fa) or not np.isfinite(fb):
|
|
1273
|
+
continue
|
|
1274
|
+
if fa == 0.0:
|
|
1275
|
+
bracket = (
|
|
1276
|
+
a - 1e-6,
|
|
1277
|
+
a + 1e-6,
|
|
1278
|
+
) # degenerate bracket around exact root
|
|
1279
|
+
break
|
|
1280
|
+
if np.sign(fa) != np.sign(fb):
|
|
1281
|
+
bracket = (a, b)
|
|
1282
|
+
break
|
|
1283
|
+
|
|
1284
|
+
if bracket is None:
|
|
1285
|
+
# Fallback: try widening upper bound up to, say, 1000%
|
|
1286
|
+
a = 0.01
|
|
1287
|
+
b = 10.0
|
|
1288
|
+
fa = npv_at(a)
|
|
1289
|
+
fb = npv_at(b)
|
|
1290
|
+
while (
|
|
1291
|
+
np.isfinite(fb)
|
|
1292
|
+
and np.sign(fa) == np.sign(fb)
|
|
1293
|
+
and b < 10.0
|
|
1294
|
+
):
|
|
1295
|
+
b *= 1.5
|
|
1296
|
+
fb = npv_at(b)
|
|
1297
|
+
bracket = (
|
|
1298
|
+
(a, b)
|
|
1299
|
+
if np.isfinite(fb)
|
|
1300
|
+
and np.sign(fa) != np.sign(fb)
|
|
1301
|
+
else None
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
if bracket is None:
|
|
1305
|
+
self.irr = float("nan")
|
|
1306
|
+
if print_results:
|
|
1307
|
+
print(
|
|
1308
|
+
"Internal Rate of Return: "
|
|
1309
|
+
"undefined (could not bracket a root)."
|
|
1310
|
+
)
|
|
1311
|
+
return self.irr
|
|
1312
|
+
|
|
1313
|
+
# 2) Root finding with Brent's method on the bracket
|
|
1314
|
+
try:
|
|
1315
|
+
sol = root_scalar(
|
|
1316
|
+
npv_at,
|
|
1317
|
+
bracket=bracket,
|
|
1318
|
+
method="brentq",
|
|
1319
|
+
xtol=1e-10,
|
|
1320
|
+
rtol=1e-10,
|
|
1321
|
+
maxiter=200,
|
|
1322
|
+
)
|
|
1323
|
+
self.irr = (
|
|
1324
|
+
sol.root
|
|
1325
|
+
if sol.converged and math.isfinite(sol.root)
|
|
1326
|
+
else float("nan")
|
|
1327
|
+
)
|
|
1328
|
+
except Exception:
|
|
1329
|
+
self.irr = float("nan")
|
|
1330
|
+
|
|
1331
|
+
if print_results:
|
|
1332
|
+
if math.isfinite(self.irr):
|
|
1333
|
+
print(
|
|
1334
|
+
f"Internal Rate of Return: {self.irr * 100:.2f}%"
|
|
1335
|
+
)
|
|
1336
|
+
else:
|
|
1337
|
+
print("Internal Rate of Return: undefined")
|
|
1338
|
+
else:
|
|
1339
|
+
return self.irr
|
|
1340
|
+
|
|
1341
|
+
def __str__(self):
|
|
1342
|
+
"""Pretty string representation of all plant configuration inputs."""
|
|
1343
|
+
|
|
1344
|
+
# Helper for formatting dicts cleanly
|
|
1345
|
+
import json
|
|
1346
|
+
|
|
1347
|
+
def fmt(obj):
|
|
1348
|
+
if obj is None:
|
|
1349
|
+
return "None"
|
|
1350
|
+
if isinstance(obj, dict):
|
|
1351
|
+
return json.dumps(obj, indent=4)
|
|
1352
|
+
return str(obj)
|
|
1353
|
+
|
|
1354
|
+
# Equipment formatting
|
|
1355
|
+
if self.equipment_list:
|
|
1356
|
+
eq_strings = []
|
|
1357
|
+
for i, eq in enumerate(self.equipment_list):
|
|
1358
|
+
label = getattr(
|
|
1359
|
+
eq,
|
|
1360
|
+
"name",
|
|
1361
|
+
f"{eq.__class__.__name__}({i})",
|
|
1362
|
+
)
|
|
1363
|
+
cost = getattr(eq, "direct_cost", "N/A")
|
|
1364
|
+
eq_strings.append(
|
|
1365
|
+
f" - {label}: direct_cost={cost}"
|
|
1366
|
+
)
|
|
1367
|
+
eq_block = "\n".join(eq_strings)
|
|
1368
|
+
else:
|
|
1369
|
+
eq_block = " None"
|
|
1370
|
+
|
|
1371
|
+
return (
|
|
1372
|
+
f"ProcessPlant Configuration\n"
|
|
1373
|
+
f"{'-'*40}\n"
|
|
1374
|
+
f"Plant Name: {self.name}\n"
|
|
1375
|
+
f"Process Type: {self.process_type}\n"
|
|
1376
|
+
f"Country / Region: {self.country} / {self.region}\n"
|
|
1377
|
+
f"Interest Rate: {self.interest_rate}\n"
|
|
1378
|
+
f"Project Lifetime (years): {self.project_lifetime}\n"
|
|
1379
|
+
f"Plant Utilization: {self.plant_utilization}\n"
|
|
1380
|
+
f"Tax Rate: {self.tax_rate}\n"
|
|
1381
|
+
f"Working Capital: {self.working_capital}\n"
|
|
1382
|
+
f"Depreciation Settings: {fmt(self.depreciation)}\n"
|
|
1383
|
+
f"\n"
|
|
1384
|
+
f"Operator Labor Inputs\n"
|
|
1385
|
+
f" Hourly Rate: {fmt(self.operator_hourly_rate)}\n"
|
|
1386
|
+
f" Operators per Shift: {self.operators_per_shift}\n"
|
|
1387
|
+
f" Operators Hired: {self.operators_hired}\n"
|
|
1388
|
+
f" Working Weeks / Year: {self.working_weeks_per_year}\n"
|
|
1389
|
+
f" Working Shifts / Week: {self.working_shifts_per_week}\n"
|
|
1390
|
+
f" Operating Shifts / Day: {self.operating_shifts_per_day}\n"
|
|
1391
|
+
f"\n"
|
|
1392
|
+
f"Products\n"
|
|
1393
|
+
f"{fmt(self.plant_products)}\n"
|
|
1394
|
+
f"\n"
|
|
1395
|
+
f"Variable OPEX Inputs:\n{fmt(self.variable_opex_inputs)}\n"
|
|
1396
|
+
f"\n"
|
|
1397
|
+
f"Additional CAPEX:\n"
|
|
1398
|
+
f" Years: {self.additional_capex_years}\n"
|
|
1399
|
+
f" Costs: {self.additional_capex_cost}\n"
|
|
1400
|
+
f"\n"
|
|
1401
|
+
f"Equipment List:\n{eq_block}\n"
|
|
1402
|
+
f"\n"
|
|
1403
|
+
f"Cost Multipliers:\n"
|
|
1404
|
+
f" fc (installed cost factor): {self.fc}\n"
|
|
1405
|
+
f" fp (fixed OPEX factor): {self.fp}\n"
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
# Depreciation models
|
|
1410
|
+
DepMethod = Literal[
|
|
1411
|
+
"straight_line", "declining_balance", "macrs"
|
|
1412
|
+
]
|
|
1413
|
+
|
|
1414
|
+
# MACRS half-year convention percentage tables (IRS Pub 946).
|
|
1415
|
+
# https://www.irs.gov/pub/irs-pdf/p946.pdf
|
|
1416
|
+
# Values are FRACTIONS (not %). Sum to 1.0 within rounding.
|
|
1417
|
+
_MACRS_HALF_YEAR: Dict[int, List[float]] = {
|
|
1418
|
+
3: [0.3333, 0.4445, 0.1481, 0.0741],
|
|
1419
|
+
5: [0.2000, 0.3200, 0.1920, 0.1152, 0.1152, 0.0576],
|
|
1420
|
+
7: [
|
|
1421
|
+
0.1429,
|
|
1422
|
+
0.2449,
|
|
1423
|
+
0.1749,
|
|
1424
|
+
0.1249,
|
|
1425
|
+
0.0893,
|
|
1426
|
+
0.0892,
|
|
1427
|
+
0.0893,
|
|
1428
|
+
0.0446,
|
|
1429
|
+
],
|
|
1430
|
+
10: [
|
|
1431
|
+
0.1000,
|
|
1432
|
+
0.1800,
|
|
1433
|
+
0.1440,
|
|
1434
|
+
0.1152,
|
|
1435
|
+
0.0922,
|
|
1436
|
+
0.0737,
|
|
1437
|
+
0.0655,
|
|
1438
|
+
0.0655,
|
|
1439
|
+
0.0656,
|
|
1440
|
+
0.0328,
|
|
1441
|
+
],
|
|
1442
|
+
15: [
|
|
1443
|
+
0.0500,
|
|
1444
|
+
0.0950,
|
|
1445
|
+
0.0855,
|
|
1446
|
+
0.0770,
|
|
1447
|
+
0.0693,
|
|
1448
|
+
0.0623,
|
|
1449
|
+
0.0590,
|
|
1450
|
+
0.0590,
|
|
1451
|
+
0.0591,
|
|
1452
|
+
0.0590,
|
|
1453
|
+
0.0591,
|
|
1454
|
+
0.0590,
|
|
1455
|
+
0.0591,
|
|
1456
|
+
0.0590,
|
|
1457
|
+
0.0591,
|
|
1458
|
+
0.0295,
|
|
1459
|
+
],
|
|
1460
|
+
20: [
|
|
1461
|
+
0.0375,
|
|
1462
|
+
0.07219,
|
|
1463
|
+
0.06677,
|
|
1464
|
+
0.06177,
|
|
1465
|
+
0.05713,
|
|
1466
|
+
0.05285,
|
|
1467
|
+
0.04888,
|
|
1468
|
+
0.04522,
|
|
1469
|
+
0.04462,
|
|
1470
|
+
0.04461,
|
|
1471
|
+
0.04462,
|
|
1472
|
+
0.04461,
|
|
1473
|
+
0.04462,
|
|
1474
|
+
0.04461,
|
|
1475
|
+
0.04462,
|
|
1476
|
+
0.04461,
|
|
1477
|
+
0.04462,
|
|
1478
|
+
0.04461,
|
|
1479
|
+
0.04462,
|
|
1480
|
+
0.04461,
|
|
1481
|
+
0.02231,
|
|
1482
|
+
],
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
class DepreciationConfig:
|
|
1487
|
+
"""
|
|
1488
|
+
Configuration for asset depreciation calculations.
|
|
1489
|
+
|
|
1490
|
+
This class defines the parameters needed to compute depreciation
|
|
1491
|
+
using various methods.
|
|
1492
|
+
|
|
1493
|
+
Attributes:
|
|
1494
|
+
method (DepMethod): The depreciation method to use.
|
|
1495
|
+
Defaults to "straight_line".
|
|
1496
|
+
Options: "straight_line", "declining_balance", "macrs".
|
|
1497
|
+
life (Optional[int]): The useful life of the asset in years.
|
|
1498
|
+
Used by straight_line and declining_balance methods.
|
|
1499
|
+
Defaults to None.
|
|
1500
|
+
db_factor (float): The declining balance factor (multiplier).
|
|
1501
|
+
Only used by the declining_balance method. Defaults to 2.0.
|
|
1502
|
+
salvage_fraction (float): The salvage value as a fraction
|
|
1503
|
+
of the initial cost.
|
|
1504
|
+
Used by straight_line and declining_balance methods.
|
|
1505
|
+
Defaults to 0.0.
|
|
1506
|
+
macrs_class (int): The MACRS property class (1-20).
|
|
1507
|
+
Only used by the macrs method. Defaults to 7.
|
|
1508
|
+
convention (str): The depreciation convention for MACRS.
|
|
1509
|
+
Only used by the macrs method. Defaults to "half_year".
|
|
1510
|
+
service_start_year (int): The year index (starting from 0)
|
|
1511
|
+
when the asset is placed in service. Defaults to 2.
|
|
1512
|
+
"""
|
|
1513
|
+
|
|
1514
|
+
method: DepMethod = "straight_line"
|
|
1515
|
+
life: Optional[int] = (
|
|
1516
|
+
None # straight_line / declining_balance
|
|
1517
|
+
)
|
|
1518
|
+
db_factor: float = 2.0 # declining_balance only
|
|
1519
|
+
salvage_fraction: float = (
|
|
1520
|
+
0.0 # straight_line / declining_balance only
|
|
1521
|
+
)
|
|
1522
|
+
macrs_class: int = 7 # macrs only
|
|
1523
|
+
convention: str = "half_year" # macrs only
|
|
1524
|
+
service_start_year: int = (
|
|
1525
|
+
2 # year index when asset is placed in service
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _normalize_dep_config(
|
|
1530
|
+
project_life: int, dep_cfg: Optional[dict]
|
|
1531
|
+
) -> DepreciationConfig:
|
|
1532
|
+
"""
|
|
1533
|
+
Normalize and validate a depreciation configuration.
|
|
1534
|
+
This function creates a DepreciationConfig object from a dictionary of
|
|
1535
|
+
configuration parameters, applies sensible defaults, and validates
|
|
1536
|
+
the configuration based on the depreciation method and project life.
|
|
1537
|
+
Args:
|
|
1538
|
+
project_life (int): The expected life of the project in years.
|
|
1539
|
+
Used to set a sensible default for the depreciation life
|
|
1540
|
+
if not specified.
|
|
1541
|
+
dep_cfg (Optional[dict]): A dictionary containing depreciation
|
|
1542
|
+
configuration parameters. Keys should correspond to
|
|
1543
|
+
DepreciationConfig attributes. If None, defaults are applied.
|
|
1544
|
+
Returns:
|
|
1545
|
+
DepreciationConfig: A validated depreciation configuration object
|
|
1546
|
+
with all required parameters set.
|
|
1547
|
+
Raises:
|
|
1548
|
+
ValueError: If the MACRS convention is not "half_year" when
|
|
1549
|
+
using the "macrs" depreciation method.
|
|
1550
|
+
ValueError: If the specified MACRS class is not supported.
|
|
1551
|
+
Only classes defined in _MACRS_HALF_YEAR are accepted.
|
|
1552
|
+
Notes:
|
|
1553
|
+
- If cfg.life is not specified, it defaults to the minimum of
|
|
1554
|
+
project_life and 15 years.
|
|
1555
|
+
- Only the "half_year" MACRS convention is currently supported.
|
|
1556
|
+
- MACRS class validation only occurs when the depreciation
|
|
1557
|
+
method is "macrs".
|
|
1558
|
+
"""
|
|
1559
|
+
cfg = DepreciationConfig()
|
|
1560
|
+
if dep_cfg:
|
|
1561
|
+
for k, v in dep_cfg.items():
|
|
1562
|
+
if hasattr(cfg, k):
|
|
1563
|
+
setattr(cfg, k, v)
|
|
1564
|
+
|
|
1565
|
+
# Sensible defaults
|
|
1566
|
+
if cfg.life is None:
|
|
1567
|
+
cfg.life = min(project_life, 15)
|
|
1568
|
+
if cfg.method == "macrs":
|
|
1569
|
+
if cfg.convention != "half_year":
|
|
1570
|
+
raise ValueError(
|
|
1571
|
+
"Only half_year MACRS convention is supported currently."
|
|
1572
|
+
)
|
|
1573
|
+
if cfg.macrs_class not in _MACRS_HALF_YEAR:
|
|
1574
|
+
raise ValueError(
|
|
1575
|
+
f"Unsupported MACRS class {cfg.macrs_class}. "
|
|
1576
|
+
f"Choose one of {sorted(_MACRS_HALF_YEAR.keys())}."
|
|
1577
|
+
)
|
|
1578
|
+
return cfg
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _straight_line_schedule(
|
|
1582
|
+
basis: float,
|
|
1583
|
+
life: int,
|
|
1584
|
+
salvage_frac: float,
|
|
1585
|
+
horizon: int,
|
|
1586
|
+
) -> np.ndarray:
|
|
1587
|
+
"""
|
|
1588
|
+
Calculate a straight-line depreciation schedule over a given horizon.
|
|
1589
|
+
|
|
1590
|
+
This function computes an annual depreciation amount based
|
|
1591
|
+
on the asset basis, useful life, and salvage value, then
|
|
1592
|
+
creates a schedule array that distributes this depreciation a
|
|
1593
|
+
cross the analysis horizon.
|
|
1594
|
+
|
|
1595
|
+
Args:
|
|
1596
|
+
basis: The initial cost or basis of the asset.
|
|
1597
|
+
life: The useful life of the asset in years.
|
|
1598
|
+
salvage_frac: The salvage value as a fraction of the basis (0 to 1).
|
|
1599
|
+
horizon: The analysis horizon in years.
|
|
1600
|
+
|
|
1601
|
+
Returns:
|
|
1602
|
+
A numpy array of shape (horizon,) containing the annual depreciation
|
|
1603
|
+
amounts. The array is zero-filled for years beyond the asset's useful
|
|
1604
|
+
life.
|
|
1605
|
+
|
|
1606
|
+
Notes:
|
|
1607
|
+
- Depreciation is distributed equally across the asset's useful life.
|
|
1608
|
+
- A rounding correction is applied to the final depreciation year to
|
|
1609
|
+
ensure the sum of the schedule equals the total depreciable amount.
|
|
1610
|
+
"""
|
|
1611
|
+
salvage = basis * salvage_frac
|
|
1612
|
+
dep_total = basis - salvage
|
|
1613
|
+
annual = dep_total / life
|
|
1614
|
+
sched = np.zeros(horizon, dtype=float)
|
|
1615
|
+
years = min(life, horizon)
|
|
1616
|
+
sched[:years] = annual
|
|
1617
|
+
# Small rounding fix to ensure sum equals dep_total
|
|
1618
|
+
diff = dep_total - sched.sum()
|
|
1619
|
+
if abs(diff) > 1e-6 and years > 0:
|
|
1620
|
+
sched[years - 1] += diff
|
|
1621
|
+
return sched
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _declining_balance_schedule(
|
|
1625
|
+
basis: float,
|
|
1626
|
+
life: int,
|
|
1627
|
+
factor: float,
|
|
1628
|
+
salvage_frac: float,
|
|
1629
|
+
horizon: int,
|
|
1630
|
+
) -> np.ndarray:
|
|
1631
|
+
"""
|
|
1632
|
+
Calculate a declining balance depreciation schedule with salvage
|
|
1633
|
+
value protection.
|
|
1634
|
+
|
|
1635
|
+
This function computes a depreciation schedule using a declining
|
|
1636
|
+
balance method that switches to straight-line depreciation
|
|
1637
|
+
when beneficial, ensuring the asset depreciates from its basis
|
|
1638
|
+
to its salvage value over the specified life.
|
|
1639
|
+
|
|
1640
|
+
Parameters
|
|
1641
|
+
----------
|
|
1642
|
+
basis : float
|
|
1643
|
+
The initial cost or book value of the asset.
|
|
1644
|
+
basis : float
|
|
1645
|
+
The initial cost or book value of the asset.
|
|
1646
|
+
life : int
|
|
1647
|
+
The useful life of the asset in years.
|
|
1648
|
+
factor : float
|
|
1649
|
+
The declining balance factor
|
|
1650
|
+
(typically 1 or 2 for standard or double declining balance).
|
|
1651
|
+
salvage_frac : float
|
|
1652
|
+
The salvage value as a fraction of the basis
|
|
1653
|
+
(e.g., 0.1 for 10% salvage).
|
|
1654
|
+
horizon : int
|
|
1655
|
+
The time horizon in years for which to generate the schedule.
|
|
1656
|
+
|
|
1657
|
+
Returns
|
|
1658
|
+
-------
|
|
1659
|
+
np.ndarray
|
|
1660
|
+
A 1D array of shape (horizon,) containing the depreciation
|
|
1661
|
+
amount for each year. Values are zero for years beyond
|
|
1662
|
+
the asset's life.
|
|
1663
|
+
|
|
1664
|
+
Notes
|
|
1665
|
+
-----
|
|
1666
|
+
- The depreciation method automatically switches between declining balance
|
|
1667
|
+
and straight-line when straight-line yields a higher depreciation
|
|
1668
|
+
amount.
|
|
1669
|
+
- The schedule respects the salvage value, preventing
|
|
1670
|
+
depreciation below it.
|
|
1671
|
+
- A rounding correction is applied to ensure the total depreciation equals
|
|
1672
|
+
(basis - salvage) within numerical precision.
|
|
1673
|
+
"""
|
|
1674
|
+
salvage = basis * salvage_frac
|
|
1675
|
+
remaining = basis
|
|
1676
|
+
sched = np.zeros(horizon, dtype=float)
|
|
1677
|
+
for y in range(min(life, horizon)):
|
|
1678
|
+
# Candidate DB amount
|
|
1679
|
+
db = remaining * (factor / life)
|
|
1680
|
+
# Candidate SL amount on remaining (including salvage protection)
|
|
1681
|
+
years_left = life - y
|
|
1682
|
+
sl_total_left = max(0.0, remaining - salvage)
|
|
1683
|
+
sl = (
|
|
1684
|
+
sl_total_left / years_left
|
|
1685
|
+
if years_left > 0
|
|
1686
|
+
else 0.0
|
|
1687
|
+
)
|
|
1688
|
+
dep = max(
|
|
1689
|
+
0.0, min(max(db, sl), remaining - salvage)
|
|
1690
|
+
) # cannot dip below salvage
|
|
1691
|
+
sched[y] = dep
|
|
1692
|
+
remaining -= dep
|
|
1693
|
+
# Tiny rounding correction
|
|
1694
|
+
diff = (basis - salvage) - sched.sum()
|
|
1695
|
+
if abs(diff) > 1e-6:
|
|
1696
|
+
last = (
|
|
1697
|
+
np.nonzero(sched)[0][-1] if sched.any() else 0
|
|
1698
|
+
)
|
|
1699
|
+
sched[last] += diff
|
|
1700
|
+
return sched
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def _macrs_schedule(
|
|
1704
|
+
basis: float, macrs_class: int, horizon: int
|
|
1705
|
+
) -> np.ndarray:
|
|
1706
|
+
"""
|
|
1707
|
+
Generate a MACRS depreciation schedule for an asset.
|
|
1708
|
+
|
|
1709
|
+
This function calculates the annual depreciation amounts using the Modified
|
|
1710
|
+
Accelerated Cost Recovery System (MACRS) half-year convention over a
|
|
1711
|
+
specified time horizon.
|
|
1712
|
+
|
|
1713
|
+
Args:
|
|
1714
|
+
basis (float): The initial cost basis of the asset to be depreciated.
|
|
1715
|
+
macrs_class (int): The MACRS asset class that determines the
|
|
1716
|
+
depreciation percentages and recovery period.
|
|
1717
|
+
horizon (int): The number of years over which to generate the
|
|
1718
|
+
depreciation schedule.
|
|
1719
|
+
|
|
1720
|
+
Returns:
|
|
1721
|
+
np.ndarray: An array of depreciation amounts for each year
|
|
1722
|
+
in the horizon. The sum of all depreciation amounts
|
|
1723
|
+
equals the basis (within floating-point tolerance).
|
|
1724
|
+
|
|
1725
|
+
Notes:
|
|
1726
|
+
- If the standard MACRS schedule is shorter than the horizon,
|
|
1727
|
+
the schedule is padded with zeros for remaining years.
|
|
1728
|
+
- If the standard MACRS schedule is longer than the horizon,
|
|
1729
|
+
it is truncated.
|
|
1730
|
+
- A rounding adjustment is applied to the final year to ensure
|
|
1731
|
+
the total depreciation does not exceed the basis.
|
|
1732
|
+
"""
|
|
1733
|
+
pct = _MACRS_HALF_YEAR[macrs_class]
|
|
1734
|
+
sched = np.array(pct, dtype=float) * basis
|
|
1735
|
+
if len(sched) < horizon:
|
|
1736
|
+
sched = np.pad(sched, (0, horizon - len(sched)))
|
|
1737
|
+
else:
|
|
1738
|
+
sched = sched[:horizon]
|
|
1739
|
+
# Rounding fix to make sure we don't exceed basis:
|
|
1740
|
+
if sched.sum() - basis > 1e-6:
|
|
1741
|
+
sched[-1] -= sched.sum() - basis
|
|
1742
|
+
return sched
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def build_depreciation_array(
|
|
1746
|
+
project_life: int,
|
|
1747
|
+
capex_by_year: Dict[int, float],
|
|
1748
|
+
dep_cfg: Optional[dict] = None,
|
|
1749
|
+
) -> np.ndarray:
|
|
1750
|
+
"""
|
|
1751
|
+
Build a depreciation schedule array over the project lifecycle.
|
|
1752
|
+
Calculates annual depreciation amounts for capital expenditures using the
|
|
1753
|
+
specified depreciation method. Supports multiple depreciation methods
|
|
1754
|
+
including straight-line,
|
|
1755
|
+
declining balance, and MACRS.
|
|
1756
|
+
Parameters
|
|
1757
|
+
----------
|
|
1758
|
+
project_life : int
|
|
1759
|
+
The total duration of the project in years.
|
|
1760
|
+
capex_by_year : Dict[int, float]
|
|
1761
|
+
Dictionary mapping year to capital expenditure amount for that year.
|
|
1762
|
+
dep_cfg : Optional[dict], optional
|
|
1763
|
+
Depreciation configuration dictionary containing method, life,
|
|
1764
|
+
salvage fraction, and method-specific parameters.
|
|
1765
|
+
If None, uses normalized default configuration.
|
|
1766
|
+
Default is None.
|
|
1767
|
+
Returns
|
|
1768
|
+
-------
|
|
1769
|
+
np.ndarray
|
|
1770
|
+
1D array of shape (project_life,) containing annual depreciation
|
|
1771
|
+
amounts. Values are floats representing depreciation in each year.
|
|
1772
|
+
Raises
|
|
1773
|
+
------
|
|
1774
|
+
ValueError
|
|
1775
|
+
If the depreciation method specified in dep_cfg is not one of the
|
|
1776
|
+
supported methods: 'straight_line', 'declining_balance', or 'macrs'.
|
|
1777
|
+
Notes
|
|
1778
|
+
-----
|
|
1779
|
+
- Capital expenditures are placed in service starting at the configured
|
|
1780
|
+
service start year.
|
|
1781
|
+
- Depreciation schedules respect the project horizon after placement
|
|
1782
|
+
in service.
|
|
1783
|
+
- Zero amounts and expired horizons are skipped without error.
|
|
1784
|
+
"""
|
|
1785
|
+
cfg = _normalize_dep_config(project_life, dep_cfg)
|
|
1786
|
+
dep = np.zeros(project_life, dtype=float)
|
|
1787
|
+
|
|
1788
|
+
for capex_year, amount in capex_by_year.items():
|
|
1789
|
+
# place-in-service timing
|
|
1790
|
+
start = max(cfg.service_start_year, capex_year)
|
|
1791
|
+
horizon = max(0, project_life - start)
|
|
1792
|
+
if horizon <= 0 or amount == 0:
|
|
1793
|
+
continue
|
|
1794
|
+
|
|
1795
|
+
if cfg.method == "straight_line":
|
|
1796
|
+
sched = _straight_line_schedule(
|
|
1797
|
+
amount,
|
|
1798
|
+
cfg.life,
|
|
1799
|
+
cfg.salvage_fraction,
|
|
1800
|
+
horizon,
|
|
1801
|
+
)
|
|
1802
|
+
elif cfg.method == "declining_balance":
|
|
1803
|
+
sched = _declining_balance_schedule(
|
|
1804
|
+
amount,
|
|
1805
|
+
cfg.life,
|
|
1806
|
+
cfg.db_factor,
|
|
1807
|
+
cfg.salvage_fraction,
|
|
1808
|
+
horizon,
|
|
1809
|
+
)
|
|
1810
|
+
elif cfg.method == "macrs":
|
|
1811
|
+
sched = _macrs_schedule(
|
|
1812
|
+
amount, cfg.macrs_class, horizon
|
|
1813
|
+
)
|
|
1814
|
+
else:
|
|
1815
|
+
raise ValueError(
|
|
1816
|
+
f"Unknown depreciation method: {cfg.method}"
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
dep[start: start + len(sched)] += sched
|
|
1820
|
+
|
|
1821
|
+
return dep
|