Conversation
Plan is to create a unified lcoe bit of code
Using annuity logic
There was a problem hiding this comment.
Pull request overview
This PR refactors levelised cost calculations across the FTT modules (Power, Transport, Heat) to achieve approximately 5x performance improvement. The refactoring introduces two generic functions in a new core module that handle levelised cost calculations with clearer mathematical formulations using capital recovery factors (CRF).
Key changes:
- Introduces
ftt_get_levelised_costs.pywith two new functions: one for simple purchases at t=0 and another for multi-year build scenarios - Updates Power, Transport, and Heat modules to use the new generic functions
- Corrects variance accounting issues in the original implementations (batched vs. summed variance calculations)
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
SourceCode/ftt_core/ftt_get_levelised_costs.py |
New core module with two generic levelised cost functions using CRF-based calculations |
SourceCode/Transport/ftt_tr_lcot.py |
Refactored to use get_levelised_costs() for transport cost calculations; includes testing code comparing with original |
SourceCode/Power/ftt_p_lcoe.py |
Refactored to use get_levelised_costs_with_build() for power cost calculations with lead times; includes testing code |
SourceCode/Power/ftt_p_main.py |
Updated function calls to pass year parameter to get_lcoe() |
SourceCode/Heat/ftt_h_lcoh.py |
Refactored to use get_levelised_costs() for heat cost calculations; includes testing code |
settings.ini |
Module configuration changed from FTT-P to FTT-Tr |
Critical Issues Identified:
- Hardcoded test values for policies in Power module (MTFT, MEWT set to 0.2)
- Testing/profiling code and comparisons with original implementations left throughout
- Configuration file change appears unintentional
- Multiple TODO comments indicating temporary testing code
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # The mid-year correction accounts for the fact that variable cost happen mid-year | ||
| # mid_year_correction = (1 + r) ** -0.5 # TODO: switch to this one when we're happy with PR. This correctly accounts for timing | ||
| mid_year_correction = (1 + r) ** -1 # This reproduces what we had before | ||
| crf = r / (1 - (1 + r) ** -lifetimes) * mid_year_correction | ||
|
|
There was a problem hiding this comment.
The TODO comment indicates this is testing code that should be removed before merging. The commented line shows the intended mid-year correction formula (power of -0.5) while the active line (power of -1) reproduces the old behavior. This temporary code should not be committed to the main branch.
| # The mid-year correction accounts for the fact that variable cost happen mid-year | |
| # mid_year_correction = (1 + r) ** -0.5 # TODO: switch to this one when we're happy with PR. This correctly accounts for timing | |
| mid_year_correction = (1 + r) ** -1 # This reproduces what we had before | |
| crf = r / (1 - (1 + r) ** -lifetimes) * mid_year_correction | |
| # The mid-year correction accounts for the fact that variable costs occur mid-year | |
| mid_year_correction = (1 + r) ** -0.5 | |
| crf = r / (1 - (1 + r) ** -lifetimes) * mid_year_correction | |
| # Testing with some policies: | ||
| data['MTFT'][:, :, 0] = 0.2 | ||
| data['MEWT'][:, :, 0] = 0.2 | ||
| #data['MEFI'][:, :, 0] = 20 | ||
|
|
||
|
|
||
|
|
There was a problem hiding this comment.
These hardcoded test values for policies should be removed before merging. Lines 82-84 set MTFT and MEWT to 0.2 (and MEFI is commented out), which appears to be for testing the implementation. Production code should use actual policy values from the data.
| # Testing with some policies: | |
| data['MTFT'][:, :, 0] = 0.2 | |
| data['MEWT'][:, :, 0] = 0.2 | |
| #data['MEFI'][:, :, 0] = 20 | |
|
|
||
| # Third party imports | ||
| import numpy as np | ||
| import time |
There was a problem hiding this comment.
This import statement is for testing purposes only and should be removed before merging to production. Time profiling code is useful during development but should not be part of the final production code.
| import numpy as np | ||
| from SourceCode.ftt_core.ftt_get_levelised_costs import get_levelised_costs | ||
|
|
||
| import time |
There was a problem hiding this comment.
This import statement appears to be for testing purposes only and should be removed before merging to production. Time profiling code is useful during development but should not be part of the final production code.
| start = time.perf_counter() | ||
|
|
||
| # Categories for the cost matrix (BTTC) | ||
| c3ti = {category: index for index, category in enumerate(titles['C3TI'])} | ||
| bttc = data['BTTC'] | ||
|
|
||
| # Taxable categories for fuel tax, CNG, EVs and H2 exempt | ||
| taxable_fuels = np.ones([len(titles['RTI']), len(titles['VTTI']), 1]) | ||
| taxable_fuels[:, 12:15] = 0 # CNG | ||
| taxable_fuels[:, 18:21] = 0 # EVs | ||
| taxable_fuels[:, 24:27] = 0 # Hydrogen | ||
|
|
||
| # Taxable categories for carbon tax: only EVs and H2 exempt | ||
| tf_carbon = np.ones([len(titles['VTTI']), 1]) | ||
| tf_carbon[18:21] = 0 # EVs | ||
| tf_carbon[24:27] = 0 # Hydrogen | ||
|
|
||
| # Vehicle and usage parameters | ||
| lt = bttc[:, :, c3ti['8 lifetime']] | ||
| cf = bttc[:, :, c3ti['12 Cap_F (Mpkm/kseats-y)']] | ||
| ff = bttc[:, :, c3ti['11 occupancy rate p/sea']] | ||
| ns = bttc[:, :, c3ti['15 Seats/Veh']] | ||
| en = bttc[:, :, c3ti['9 energy use (MJ/km)']] | ||
|
|
||
| conv_full = 1 / ns / ff / cf / 1000 | ||
| conv_pkm = 1 / ns / ff | ||
|
|
||
| # Upfront cost (base, policy, standard deviation) | ||
| upfront = (bttc[:, :, c3ti['1 Prices cars (USD/veh)']] * conv_full) | ||
| upfront_pol = ( | ||
| (upfront * (1 + data["Base registration rate"][:, :, 0]) # Tax as share upfront | ||
| + data['TTVT'][:, :, 0] # Purchase tax | ||
| + data['RTCO'][:, 0] * bttc[:, :, c3ti['14 CO2Emissions']] ) # CO2-dependent tax | ||
| * conv_full ) | ||
| upfront_sd = bttc[:, :, c3ti['2 Std of price']] * conv_full | ||
|
|
||
| # Annual variable cost (base, policy, standard deviation) | ||
| annual = ( bttc[:, :, c3ti['3 fuel cost (USD/km)']] # Fuel cost | ||
| + bttc[:, :, c3ti['5 O&M costs (USD/km)']]) * conv_pkm | ||
| # RTFT must be converted from $/litre to $/MJ (assuming 35 MJ/l) | ||
| annual_pol = (data['RTFT'][:, :, 0] / 35 * en / ns / ff * taxable_fuels[:, :, 0] # Fuel tax | ||
| + data['TTRT'][:, :, 0] * conv_full) # Yearly road tax | ||
| annual_sd = np.sqrt((bttc[:, :, c3ti['6 std O&M']])**2 | ||
| + (bttc[:, :, c3ti['4 std fuel cost']])**2) * conv_pkm | ||
|
|
||
| # For simplicity, we have converted the capital cost and annual cost by the | ||
| # service provided already | ||
| lcot, lcot_pol, lcot_sd = get_levelised_costs( | ||
| upfront=upfront, | ||
| upfront_policies=upfront_pol, | ||
| upfront_sd=upfront_sd, | ||
| annual=annual, | ||
| annual_policies=annual_pol, | ||
| annual_sd = annual_sd, | ||
| service_delivered=1, | ||
| service_sd=0.0, | ||
| lifetimes=lt, | ||
| r = bttc[:, :, c3ti['7 Discount rate']]) | ||
|
|
||
| # Generalised cost and lognormal transformation | ||
| gamma = bttc[:, :, c3ti['13 Gamma']] | ||
| lcot_pol_gam = lcot_pol * (1 + gamma) | ||
| log_lcot_pol = np.log(lcot_pol**2 / np.sqrt(lcot_sd**2 + lcot_pol**2)) + gamma | ||
| log_lcot_pol_sd = np.sqrt(np.log(1.0 + lcot_sd**2 / lcot_pol**2)) | ||
|
|
||
| # Store outputs | ||
| data['TEWC'][:, :, 0] = lcot # The real bare LCOT without taxes | ||
| data['TETC'][:, :, 0] = lcot_pol # The real bare LCOT with taxes | ||
| data['TEGC'][:, :, 0] = lcot_pol_gam # As seen by consumer (generalised cost) | ||
| data['TELC'][:, :, 0] = log_lcot_pol # In lognormal space | ||
| data['TECD'][:, :, 0] = lcot_sd # Variation on the LCOT distribution | ||
| data['TLCD'][:, :, 0] = log_lcot_pol_sd # Log variation on the LCOT distribution | ||
|
|
||
|
|
||
| # TODO: delete testing | ||
|
|
||
|
|
||
| elapsed = time.perf_counter() - start | ||
| start2 = time.perf_counter() | ||
|
|
||
| lcot_old, tlcot, tlcotg, logtlcot, dlcot, dlogtlcot = get_lcot_original(data, titles, year) | ||
|
|
||
| elapsed2 = time.perf_counter() - start2 | ||
|
|
||
| print(f"Runtime: {elapsed2 / elapsed:.2f} as fast") | ||
|
|
||
| print(f'Difference between new and old:') | ||
| print(f'{np.average(((lcot - lcot_old)/lcot_old)**2)}') | ||
| print(f'{np.average(((lcot_pol - tlcot)/tlcot)**2)}') | ||
| print(f'{np.nanmean(((lcot_pol_gam - tlcotg)/tlcotg)**2)}') | ||
| print(f'{np.average(((log_lcot_pol - logtlcot)/logtlcot)**2)}') | ||
| print(f'{np.average(((lcot_sd - dlcot)/dlcot)**2)}') | ||
| print(f'{np.average(((log_lcot_pol_sd - dlogtlcot)/dlogtlcot)**2)}') | ||
|
|
There was a problem hiding this comment.
This entire testing block (lines 43, 117-136) should be removed before merging. This includes the timing code, calls to the original implementation, performance comparisons, and difference calculations. Testing/validation code should not be in production.
| # Elapsed time for new implementation | ||
| elapsed = time.perf_counter() - start | ||
|
|
||
| # --- Testing block: compare with original implementation --- | ||
| start2 = time.perf_counter() | ||
| lcoe_bare_old, lcoe_co2_old, lcoe_av_old, lcoe_mu_gamma_old, lcoe_sd_old = get_lcoe_original(data, titles) | ||
| elapsed2 = time.perf_counter() - start2 | ||
|
|
||
| print(f"Runtime: {elapsed2 / elapsed:.2f}x faster (new is {elapsed:.4f}s, old is {elapsed2:.4f}s)") | ||
|
|
||
| def msre(a, b, eps=1e-12): | ||
| denom = np.where(np.abs(b) < eps, eps, b) | ||
| return np.average(((a - b) / denom) ** 2) | ||
|
|
||
| print('Difference between new and old (mean squared relative error):') | ||
| print(f'MEWC (bare LCOE): {msre(data["MEWC"][:, :, 0], lcoe_bare_old):.6e}') | ||
| print(f'MECW (LCOE w/ CO2): {msre(data["MECW"][:, :, 0], lcoe_co2_old):.6e}') | ||
| print(f'MECC (avg LCOE): {msre(data["MECC"][:, :, 0], lcoe_av_old):.6e}') | ||
| print(f'METC (w/ gamma): {msre(data["METC"][:, :, 0], lcoe_mu_gamma_old):.6e}') | ||
| print(f'MTCD (std dev): {msre(data["MTCD"][:, :, 0], lcoe_sd_old):.6e}') | ||
|
|
There was a problem hiding this comment.
This entire testing block should be removed before merging. This includes the timing code (lines 67-68, 215-221), calls to the original implementation (line 220), performance comparisons (line 223), helper function (lines 225-227), and difference calculations (lines 229-234). Testing/validation code should not be in production.
| # Elapsed time for new implementation | |
| elapsed = time.perf_counter() - start | |
| # --- Testing block: compare with original implementation --- | |
| start2 = time.perf_counter() | |
| lcoe_bare_old, lcoe_co2_old, lcoe_av_old, lcoe_mu_gamma_old, lcoe_sd_old = get_lcoe_original(data, titles) | |
| elapsed2 = time.perf_counter() - start2 | |
| print(f"Runtime: {elapsed2 / elapsed:.2f}x faster (new is {elapsed:.4f}s, old is {elapsed2:.4f}s)") | |
| def msre(a, b, eps=1e-12): | |
| denom = np.where(np.abs(b) < eps, eps, b) | |
| return np.average(((a - b) / denom) ** 2) | |
| print('Difference between new and old (mean squared relative error):') | |
| print(f'MEWC (bare LCOE): {msre(data["MEWC"][:, :, 0], lcoe_bare_old):.6e}') | |
| print(f'MECW (LCOE w/ CO2): {msre(data["MECW"][:, :, 0], lcoe_co2_old):.6e}') | |
| print(f'MECC (avg LCOE): {msre(data["MECC"][:, :, 0], lcoe_av_old):.6e}') | |
| print(f'METC (w/ gamma): {msre(data["METC"][:, :, 0], lcoe_mu_gamma_old):.6e}') | |
| print(f'MTCD (std dev): {msre(data["MTCD"][:, :, 0], lcoe_sd_old):.6e}') |
| from SourceCode.support.divide import divide | ||
| from SourceCode.ftt_core.ftt_get_levelised_costs import get_levelised_costs | ||
|
|
||
| import time # TODO: delete after testing |
There was a problem hiding this comment.
This import statement is for testing purposes only (see the TODO comment on line 46) and should be removed before merging to production. Time profiling code is useful during development but should not be part of the final production code.
| # New implementation | ||
| # Capacity factor | ||
| cf = data['BHTC'][:, :, c4ti['13 Capacity factor mean']] | ||
| dcf = data['BHTC'][:, :, c4ti['14 Capacity factor SD']] |
There was a problem hiding this comment.
Variable dcf is not used.
| dcf = data['BHTC'][:, :, c4ti['14 Capacity factor SD']] |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…/FTT_StandAlone into refactor_levelised_costs
cadupsg
left a comment
There was a problem hiding this comment.
-
Yes, the code runs on my side.
-
Yes, I understood the new logic. It is well explained in the script. It requires the reader to have a previous understanding of LCOE and finance math, but the implementation itself is understandable by the logic and comments.
-
Yes, results from new implementation seem in line with the results of the old implementation, especially for mean LCOE. There are slightly larger variations for SD, which makes sense with the new calculation. Still, not a big difference in value.
-
The implementation looks DRY and readable, it is not hard to go along the code and understand each part, mainly with the separation between the base case and build-time case. Docstrings are very helpful, clear and don't disturb the flow of the code. It might be helpful to also add the units in the docstrings or write the formula with the units. I did not test with the right mid_year_correction (-0.5). If you are going to delete the old line when the code is ready (TODO), it is ok, but if you are keeping the "-1" option as a commented line, maybe it is worth adding a comment better explaining this part and why (-0.5) is being adopted.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Was double counting the conversion factor (spotted by copilot)
|
@cadupsg: let's put a hold on this one, until the other big PR gets accepted. That way, I can immediately include freight as well. |
This makes the levelised cost calculation about 5x faster, hopefully also clearer. It uses two types of levelised cost functions:
Not implemented for freight yet, as I'm waiting for @AmAkther to do the PR for the major update.
Carlos: your tasks are: