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/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