From 6c1ca14aa2c1e009728c6d8524eb4b1dfe7fbcf5 Mon Sep 17 00:00:00 2001 From: Peplinski Date: Fri, 21 Aug 2026 12:16:30 -0400 Subject: [PATCH 1/4] Allocate cost of imported emissions to bus table and add to retail price estimate --- src/results/results_formulas.csv | 1 + src/results/retail_price.jl | 9 ++++++ src/types/policies/EmissionCap.jl | 50 +++++++++++++++++++++++++++-- src/types/policies/EmissionPrice.jl | 47 +++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/results/results_formulas.csv b/src/results/results_formulas.csv index 0e5b3567..44dc8052 100644 --- a/src/results/results_formulas.csv +++ b/src/results/results_formulas.csv @@ -106,6 +106,7 @@ bus,distribution_cost_total,"SumHourlyWeighted(plserv, distribution_cost)",Dolla bus,unserved_load_cost_total, "SumHourlyWeighted(plcurt, voll)",Dollars,Total cost of unserved load. bus,gs_payment,0,Dollars,Cost of required credits for clean/renewable generation for all generation standards (RPS's and CES's) for the qualifying demand at each given bus. bus,emission_cost,0,Dollars,Cost for paying all emissions prices for imported energy. +bus,emission_cap_cost,0,Dollars,Cost attributed to imports for all emission caps, allocated to buses. branch,eflow_total,SumHourlyWeighted(pflow),MWhFlow,Total energy flowing in this branch branch,pflow_hourly_min,MinHourly(pflow),MWFlow,Minimum sum of power flowing in these branches branch,pflow_hourly_max,MaxHourly(pflow),MWFlow,Maximum sum of power flowing in these branches diff --git a/src/results/retail_price.jl b/src/results/retail_price.jl index 1e87b391..78fa95c7 100644 --- a/src/results/retail_price.jl +++ b/src/results/retail_price.jl @@ -47,6 +47,15 @@ function setup_retail_price!(config, data) # payments for RPS and CES policies add_price_term!(data, :avg_elec_rate, :bus, :gs_payment, +) + # cost from imported power under emission caps and emission prices, allocated to the importing + # bus by EmissionCap/EmissionPrice (branch/dc_line have no area columns of their own). + # these results formulas default to 0 if there is no emission cap/price or the mods do not cover imports + add_price_term!(data, :avg_elec_rate, :bus, :emission_cap_cost, +) + add_price_term!(data, :avg_elec_rate, :bus, :emission_cost, +) + + + # check for price terms and add + # past invest file will overwrite the past invest column of the gen table if haskey(config, :past_invest_file) add_price_term!(data, :avg_elec_rate, :past_invest, :cost_of_service_past_costs, +) diff --git a/src/types/policies/EmissionCap.jl b/src/types/policies/EmissionCap.jl index a62d6f1e..29c10d6b 100644 --- a/src/types/policies/EmissionCap.jl +++ b/src/types/policies/EmissionCap.jl @@ -318,13 +318,18 @@ function add_import_results!(data, table_name, pol::EmissionCap, cols, alw_prc) # results formula for cost of emission cap policy contributed by imports add_results_formula!(data, table_name, cols.import_cost, "SumHourlyWeighted($(cols.prc), pflow)*(1-pol.offset)", - Dollars, "The cost of $(pol.name) attributed to imports on $(table_name)") + Dollars, "The cost of $(pol.name) attributed to imports on $(table_name). Import costs have also been allocated to the corresponding busses in the bus table's emission_cap_cost result formula.") # setup a results formula to track total cost of all emission cap policies for imported power haskey(get_results_formulas(data), (table_name, :emission_cap_cost)) || add_results_formula!(data, table_name, :emission_cap_cost, "0", Dollars, - "Cost attributed to imports for all emission caps on $(table_name)") + "Cost attributed to imports for all emission caps on $(table_name). Import costs have aslo been allocated to the corresponding busses in the bus table's emission_cap_cost result formula") add_to_results_formula!(data, table_name, :emission_cap_cost, cols.import_cost) + # attribute the import cost to the importing bus, so that it can be aggregated/filtered by + # any area available on the bus table (e.g. state) - branch/dc_line rows span two areas and + # have no area columns of their own. + add_import_cost_to_bus!(data, table_name, pol, cols, prc_col) + # results formula to track associated emissions from imported power if pol.emis_col == "emis_co2" unit = ShortTons @@ -334,6 +339,47 @@ function add_import_results!(data, table_name, pol::EmissionCap, cols, alw_prc) add_results_formula!(data, table_name, cols.import_emis_result, "SumHourlyWeighted($(cols.import_emis), (pflow .* $(cols.flag)))", unit, "Total emissions from imported power under $(pol.name). Note the imported emissions are calculated using the exogenous ef inputs and do not reflect the actual ef of the model run.") end +""" + add_import_cost_to_bus!(data, table_name, pol::EmissionCap, cols, prc_col) + +Allocates the per-row import cost computed in [`add_import_results!`](@ref) (using the same +per-row price container `prc_col`) onto the importing bus,which is the endpoint inside the capped +region (`t_bus_idx` when `dir > 0`, `f_bus_idx` when `dir < 0`). This mirrors how branch-level +merchandising surplus is allocated to buses in `parse_lmp_results!`. This is necessary to compute +results like retail price at the state level, since the branch and dc line table have no area +columns. +""" +function add_import_cost_to_bus!(data, table_name, pol::EmissionCap, cols, prc_col) + table = get_table(data, table_name) + bus = get_table(data, :bus) + nyr = get_num_years(data) + nhr = get_num_hours(data) + hour_weights = get_hour_weights(data) + + bus_cost_col = Symbol("$(pol.name)_import_cost") + bus_cost_total = Symbol("$(pol.name)_import_cost_total") + if !hasproperty(bus, bus_cost_col) + add_table_col!(data, :bus, bus_cost_col, Container[ByYearAndHour(zeros(nyr, nhr)) for _ in 1:nrow(bus)], Dollars, + "Cost of $(pol.name) attributed to imports, allocated to the importing bus.") + add_results_formula!(data, :bus, bus_cost_total, "SumHourly($(bus_cost_col))", Dollars, + "Total cost of $(pol.name) attributed to imports, allocated to buses.") + haskey(get_results_formulas(data), (:bus, :emission_cap_cost)) || + add_results_formula!(data, :bus, :emission_cap_cost, "0", Dollars, + "Cost attributed to imports for all emission caps, allocated to buses.") + add_to_results_formula!(data, :bus, :emission_cap_cost, bus_cost_total) + end + + for (row_idx, row) in enumerate(eachrow(table)) + dir = row[cols.dir] + dir == 0 && continue + bus_idx = dir > 0 ? row[:t_bus_idx] : row[:f_bus_idx] + prc = prc_col[row_idx] + for y in 1:nyr, h in 1:nhr + bus[bus_idx, bus_cost_col][y,h] += hour_weights[h] * prc[y,h] * row[:pflow][y,h] * (1-pol.offset) + end + end +end + """ tag_import_flows!(table, col::Symbol) -> Tag rows in branch or dc line tables where power is imported into the EmissionCap region.. diff --git a/src/types/policies/EmissionPrice.jl b/src/types/policies/EmissionPrice.jl index 6116ecd3..38e7d4b9 100644 --- a/src/types/policies/EmissionPrice.jl +++ b/src/types/policies/EmissionPrice.jl @@ -225,6 +225,11 @@ function add_import_results!(data, table_name, pol::EmissionPrice, col::Symbol) "The total cost of imported emissions for $(table_name).") add_to_results_formula!(data, table_name, :emission_cost, cols.import_cost) + # attribute the import cost to the importing bus, so that it can be aggregated/filtered by + # any area available on the bus table (e.g. state) - branch/dc_line rows span two areas and + # have no area columns of their own. + add_import_cost_to_bus!(data, table_name, pol, col) + if pol.emis_col == "emis_co2" unit = ShortTons else @@ -234,6 +239,48 @@ function add_import_results!(data, table_name, pol::EmissionPrice, col::Symbol) add_results_formula!(data, table_name, cols.import_emis_result, "SumHourlyWeighted($(cols.import_emis), (pflow .* $(cols.flag)))", unit, "Total emissions from imported power under $(pol.name). Note the imported emissions are calculated using the exogenous ef inputs and do not reflect the actual ef of the model run.") end +""" + add_import_cost_to_bus!(data, table_name, pol::EmissionPrice, col::Symbol) +Allocates the per-row import cost computed in [`add_import_results!`](@ref) (using the same +per-row price container `prc_col`) onto the importing bus,which is the endpoint inside the capped +region (`t_bus_idx` when `dir > 0`, `f_bus_idx` when `dir < 0`). This mirrors how branch-level +merchandising surplus is allocated to buses in `parse_lmp_results!`. This is necessary to compute +results like retail price at the state level, since the branch and dc line table have no area +columns. +""" +function add_import_cost_to_bus!(data, table_name, pol::EmissionPrice, col::Symbol) + table = get_table(data, table_name) + bus = get_table(data, :bus) + cols = _emisprc_colnames(pol, table_name) + nyr = get_num_years(data) + nhr = get_num_hours(data) + hour_weights = get_hour_weights(data) + bus_set = Set(get_row_idxs(bus, parse_comparisons(pol.bus_filters))) + + bus_cost_col = Symbol("$(pol.name)_import_cost") + bus_cost_total = Symbol("$(pol.name)_import_cost_total") + if !hasproperty(bus, bus_cost_col) + add_table_col!(data, :bus, bus_cost_col, Container[ByYearAndHour(zeros(nyr, nhr)) for _ in 1:nrow(bus)], Dollars, + "Cost of $(pol.name) attributed to imports, allocated to the importing bus.") + add_results_formula!(data, :bus, bus_cost_total, "SumHourly($(bus_cost_col))", Dollars, + "Total cost of $(pol.name) attributed to imports, allocated to buses.") + haskey(get_results_formulas(data), (:bus, :emission_cost)) || + add_results_formula!(data, :bus, :emission_cost, "0", Dollars, + "The total cost of imported emissions, allocated to buses.") + add_to_results_formula!(data, :bus, :emission_cost, bus_cost_total) + end + + for row in eachrow(table) + t_in = row[:t_bus_idx] in bus_set + f_in = row[:f_bus_idx] in bus_set + t_in == f_in && continue # not a branch/dc_line crossing the priced region boundary + bus_idx = t_in ? row[:t_bus_idx] : row[:f_bus_idx] + for y in 1:nyr, h in 1:nhr + bus[bus_idx, bus_cost_col][y,h] += hour_weights[h] * row[col][y,h] * row[:pflow][y,h] * row[cols.flag][y,h] + end + end +end + """ tag_import_flows!(table, col::Symbol) -> From 1dd26e5d82cf8499fe88b56e4060b524808337ec Mon Sep 17 00:00:00 2001 From: Peplinski Date: Fri, 21 Aug 2026 12:17:14 -0400 Subject: [PATCH 2/4] Update test for updated result formulas --- test/testpoltypes.jl | 6 ++++++ test/testretailprice.jl | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/test/testpoltypes.jl b/test/testpoltypes.jl index 0e41eef8..48e75399 100644 --- a/test/testpoltypes.jl +++ b/test/testpoltypes.jl @@ -442,6 +442,9 @@ target_2040 = config[:mods][:example_emiscap_arch][:targets][:y2040] @test total_emis_2040 <= target_2040 || total_emis_2040 ≈ target_2040 + # check that the results formulas on the branch and bus tables are aligned + @test compute_result(data, :branch, :emission_cap_cost) + compute_result(data, :dc_line, :emission_cap_cost) ≈ compute_result(data, :bus, :emission_cap_cost) + end end @@ -762,6 +765,9 @@ @test compute_result(data, :branch, :example_emisprc_arch_import_emis, (:), "y2040") > 0 @test compute_result(data, :dc_line, :example_emisprc_arch_import_emis, (:), "y2040") > 0 + # check that the results formulas on the branch and bus tables are aligned + @test compute_result(data, :branch, :emission_cost) + compute_result(data, :dc_line, :emission_cost) ≈ compute_result(data, :bus, :emission_cost) + end end diff --git a/test/testretailprice.jl b/test/testretailprice.jl index f26ad996..ecfb0d25 100644 --- a/test/testretailprice.jl +++ b/test/testretailprice.jl @@ -26,7 +26,7 @@ #check retail price terms @test all(k -> haskey(retail_price, k), [:bus, :gen, :storage, :past_invest]) bus_terms = retail_price[:bus] - @test all(k -> haskey(bus_terms, k), [:electricity_cost, :distribution_cost_total, :merchandising_surplus_comp_total, :gs_payment, :state_reserve_cost]) + @test all(k -> haskey(bus_terms, k), [:electricity_cost, :distribution_cost_total, :merchandising_surplus_comp_total, :gs_payment, :state_reserve_cost, :emission_cap_cost, :emission_cost]) gen_terms = retail_price[:gen] @test haskey(gen_terms, :cost_of_service_rebate) storage_terms = retail_price[:storage] From 3f1e6ee84f0ec6367743e34ad62116a3b2558992 Mon Sep 17 00:00:00 2001 From: Peplinski Date: Fri, 21 Aug 2026 12:18:22 -0400 Subject: [PATCH 3/4] Fix postprocessing error that occurs when both import_ef and import_ef_file are saved to the config file --- src/io/config.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/io/config.jl b/src/io/config.jl index fa80b49a..9301a0c9 100644 --- a/src/io/config.jl +++ b/src/io/config.jl @@ -498,7 +498,7 @@ function make_paths_absolute!(config, filename) end make_paths_absolute!(config) = make_paths_absolute!(config, config[:config_file]) -_abspath(path, fn::AbstractString) = abspath(path, fn) +_abspath(path, fn::AbstractString) = isempty(fn) ? fn : abspath(path, fn) _abspath(path, fns::AbstractVector{<:AbstractString}) = map(fn->(isabspath(fn) ? fn : abspath(path, fn)), fns) """ contains_file_or_path(s) -> Bool From de03cd418a83c70af89fb2fdc1d94a0656a205de Mon Sep 17 00:00:00 2001 From: Peplinski Date: Fri, 21 Aug 2026 13:22:21 -0400 Subject: [PATCH 4/4] Upate to config test to improve code cov report --- test/testsaveconfig.jl | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/testsaveconfig.jl b/test/testsaveconfig.jl index 14280ae4..7181a289 100644 --- a/test/testsaveconfig.jl +++ b/test/testsaveconfig.jl @@ -60,5 +60,19 @@ newconfig = nothing + @testset "YAML printing of NamedTuple config values" begin + # Modification/Policy fields aren't required to be OrderedDicts - a field could be a + # NamedTuple, which YAML.jl doesn't know how to serialize on its own. E4ST teaches it how + # via a YAML._print(io, ::NamedTuple, ...) override (see config.jl), converting the + # NamedTuple to an OrderedDict before printing. Test that override directly here. + nt = (a = 1, b = "two", c = [1, 2, 3]) + io = IOBuffer() + YAML.write(io, nt) + yaml_str = String(take!(io)) + + reloaded = YAML.load(yaml_str, dicttype = OrderedDict{Symbol, Any}) + @test reloaded == OrderedDict(pairs(nt)) + end + end