diff --git a/CHANGELOG.md b/CHANGELOG.md index 9581b3d90..c274b07fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ Classify the change according to the following categories: ##### Removed ### Patches +## v3.17.0 +### Minor Updates +##### Added +- `ElectricLoad` input `monthly_peaks_kw`. Can be used to scale loads_kw or doe_reference loads to monthly peaks while maintaining monthly energy. +- `ElectricTariff` outputs: `demand_rate_average_series`, `energy_cost_series_before_tax`, `energy_cost_series_before_tax_bau`, `energy_rate_average_series`, `energy_rate_series`, `energy_rate_tier_limits`, `facility_demand_monthly_rate_series`, `facility_demand_monthly_rate_tier_limits`, `monthly_demand_cost_series_before_tax`, `monthly_demand_cost_series_before_tax_bau`, `monthly_energy_cost_series_before_tax`, `monthly_energy_cost_series_before_tax_bau`, `monthly_facility_demand_cost_series_before_tax`, `monthly_facility_demand_cost_series_before_tax_bau`, `monthly_fixed_cost_series_before_tax`, `monthly_fixed_cost_series_before_tax_bau`, `monthly_tou_demand_cost_series_before_tax`, `monthly_tou_demand_cost_series_before_tax_bau`, `tou_demand_metrics`, `tou_demand_rate_series`, `tou_demand_rate_tier_limits`. +- New endpoint `/get_load_metrics` for sending a timeseries `load_profile` and getting monthly and annual energy and peak loads. +- New custom table option `custom_table_rates` for endpoint `/job/generate_results_table`. +##### Fixed +- Avoid `CST` bypassing non-servable heating loads by going through the `HighTempThermalStorage`. + + ## v3.16.2 ### Patches - Added `CST` and `HighTempThermalStorage` to all/superset inputs test. diff --git a/julia_src/Manifest.toml b/julia_src/Manifest.toml index 196aae6bd..b0930d9ab 100644 --- a/julia_src/Manifest.toml +++ b/julia_src/Manifest.toml @@ -948,9 +948,9 @@ version = "1.11.0" [[deps.REopt]] deps = ["ArchGDAL", "CSV", "CoolProp", "DataFrames", "Dates", "DelimitedFiles", "HTTP", "JLD", "JSON", "JuMP", "LinDistFlow", "LinearAlgebra", "Logging", "MathOptInterface", "Requires", "Roots", "Statistics", "TestEnv"] -git-tree-sha1 = "103761fa0f7447377726347af656cde6ab1160cc" +git-tree-sha1 = "00bb39c8f932a3320960f01adc139229c24e12b7" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" -version = "0.55.1" +version = "0.56.2" [[deps.Random]] deps = ["SHA"] diff --git a/julia_src/http.jl b/julia_src/http.jl index 1c9e36d99..d2602379e 100644 --- a/julia_src/http.jl +++ b/julia_src/http.jl @@ -91,6 +91,7 @@ function reopt(req::HTTP.Request) # Catch handled/unhandled exceptions in data pre-processing, JuMP setup try model_inputs = reoptjl.REoptInputs(d) + @info "Successfully processed REopt inputs." catch e @error "Something went wrong during REopt inputs processing!" exception=(e, catch_backtrace()) error_response["error"] = sprint(showerror, e) @@ -102,6 +103,7 @@ function reopt(req::HTTP.Request) # Catch handled/unhandled exceptions in optimization try results = reoptjl.run_reopt(ms, model_inputs) + @info "Successfully ran REopt optimization." inputs_with_defaults_from_julia_financial = [ :NOx_grid_cost_per_tonne, :SO2_grid_cost_per_tonne, :PM25_grid_cost_per_tonne, :NOx_onsite_fuelburn_cost_per_tonne, :SO2_onsite_fuelburn_cost_per_tonne, :PM25_onsite_fuelburn_cost_per_tonne, @@ -235,6 +237,14 @@ function reopt(req::HTTP.Request) high_temp_storage_dict = Dict(key=>getfield(model_inputs.s.storage.attr["HighTempThermalStorage"], key) for key in inputs_with_defaults_from_julia_high_temp_storage) else high_temp_storage_dict = Dict() + end + if haskey(d, "ElectricTariff") && !isempty(model_inputs.s.electric_tariff.urdb_metadata) + inputs_from_julia_electric_tariff = [ + :urdb_metadata + ] + electric_tariff_dict = Dict(key=>getfield(model_inputs.s.electric_tariff, key) for key in inputs_from_julia_electric_tariff) + else + electric_tariff_dict = Dict() end inputs_with_defaults_set_in_julia = Dict( "Financial" => Dict(key=>getfield(model_inputs.s.financial, key) for key in inputs_with_defaults_from_julia_financial), @@ -251,7 +261,8 @@ function reopt(req::HTTP.Request) "ElectricStorage" => electric_storage_dict, "ColdThermalStorage" => cold_storage_dict, "HotThermalStorage" => hot_storage_dict, - "HighTempThermalStorage" => high_temp_storage_dict + "HighTempThermalStorage" => high_temp_storage_dict, + "ElectricTariff" => electric_tariff_dict ) catch e @error "Something went wrong in REopt optimization!" exception=(e, catch_backtrace()) @@ -544,18 +555,18 @@ function simulated_load(req::HTTP.Request) end # Convert vectors which come in as Vector{Any} to Vector{Float} (within Vector{<:Real}) - vector_types = ["percent_share", "cooling_pct_share", "monthly_totals_kwh", "monthly_mmbtu", + vector_types = ["percent_share", "cooling_pct_share", "monthly_totals_kwh", "monthly_peaks_kw", "monthly_mmbtu", "monthly_tonhour", "monthly_fraction", "addressable_load_fraction", "load_profile"] for key in vector_types if key in keys(d) && typeof(d[key]) <: Vector{} - d[key] = convert(Vector{Real}, d[key]) + d[key] = convert(Vector{Float64}, d[key]) elseif key in keys(d) && key == "addressable_load_fraction" # Scalar version of input, convert Any to Real - d[key] = convert(Real, d[key]) + d[key] = convert(Float64, d[key]) end end - @info "Getting CRB Loads..." + @info "Getting Loads..." data = Dict() error_response = Dict() try @@ -574,6 +585,35 @@ function simulated_load(req::HTTP.Request) end end +function get_load_metrics(req::HTTP.Request) + d = JSON.parse(String(req.body)) + + # Convert load_profile from Vector{Any} to Vector{Float64} + if "load_profile" in keys(d) && typeof(d["load_profile"]) <: Vector{} + d["load_profile"] = convert(Vector{Float64}, d["load_profile"]) + end + + @info "Getting load metrics..." + data = Dict() + error_response = Dict() + try + load_profile = pop!(d, "load_profile") + other_kwargs = reoptjl.dictkeys_tosymbols(d) + data = reoptjl.get_load_metrics(load_profile; other_kwargs...) + catch e + @error "Something went wrong in the get_load_metrics" exception=(e, catch_backtrace()) + error_response["error"] = sprint(showerror, e) + end + if isempty(error_response) + @info "Load metrics determined." + response = data + return HTTP.Response(200, JSON.json(response)) + else + @info "An error occured in the get_load_metrics endpoint" + return HTTP.Response(500, JSON.json(error_response)) + end +end + function ghp_efficiency_thermal_factors(req::HTTP.Request) d = JSON.parse(String(req.body)) @@ -779,4 +819,5 @@ HTTP.register!(ROUTER, "GET", "/health", health) HTTP.register!(ROUTER, "GET", "/get_existing_chiller_default_cop", get_existing_chiller_default_cop) HTTP.register!(ROUTER, "GET", "/get_ashp_defaults", get_ashp_defaults) HTTP.register!(ROUTER, "GET", "/pv_cost_defaults", pv_cost_defaults) +HTTP.register!(ROUTER, "GET", "/get_load_metrics", get_load_metrics) HTTP.serve(ROUTER, "0.0.0.0", 8081, reuseaddr=true) diff --git a/reoptjl/custom_table_config.py b/reoptjl/custom_table_config.py index e99dc0ea8..bea0a1075 100644 --- a/reoptjl/custom_table_config.py +++ b/reoptjl/custom_table_config.py @@ -1294,5 +1294,745 @@ "name": "Placeholder Calculation With BAU Reference", "formula": lambda col, bau, headers: f'=({bau["placeholder1_value"]}-{col}{headers["Placeholder2"] + 2})/{bau["placeholder1_value"]}' # This formula calculates the percentage change of Placeholder2 using Placeholder1's BAU value as the reference. + }, + + # custom_table_rates Specific Calculations + { + "name": "Change in Year 1 Total Charges ($)", + "formula": lambda col, bau, headers: f'={col}{headers["Year 1 Total Bill Charges ($)"] + 2}-{"B"}{headers["Year 1 Total Bill Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Fixed Charges Percent Change (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Fixed Charges ($)"] + 2}-{"B"}{headers["Year 1 Annual Fixed Charges ($)"] + 2})/{"B"}{headers["Year 1 Annual Fixed Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Energy Charges Percent Change (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Energy Charges ($)"] + 2}-{"B"}{headers["Year 1 Annual Energy Charges ($)"] + 2})/{"B"}{headers["Year 1 Annual Energy Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Demand Charges Percent Change (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Demand Charges ($)"] + 2}-{"B"}{headers["Year 1 Annual Demand Charges ($)"] + 2})/{"B"}{headers["Year 1 Annual Demand Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Total Bill Charges Percent Change (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Total Bill Charges ($)"] + 2}-{"B"}{headers["Year 1 Annual Total Bill Charges ($)"] + 2})/{"B"}{headers["Year 1 Annual Total Bill Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Fixed Charges Percent of Total Bill (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Fixed Charges ($)"] + 2})/{col}{headers["Year 1 Annual Total Bill Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Energy Charges Percent of Total Bill (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Energy Charges ($)"] + 2})/{col}{headers["Year 1 Annual Total Bill Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + }, + { + "name": "Year 1 Demand Charges Percent of Total Bill (%)", + "formula": lambda col, bau, headers: f'=({col}{headers["Year 1 Annual Demand Charges ($)"] + 2})/{col}{headers["Year 1 Annual Total Bill Charges ($)"] + 2}' + # This formula calculates the percentage change of scenario 2 vs. scenario 1. + } +] + + + +custom_table_rates = [ +##################################################################################################### +################################ Need to get the RATE NAME to appear in header ################ +##################################################################################################### + + # { + # "label": "Rate Headers", + # "key": "site", + # "bau_value": lambda df: "", + # "scenario_value": lambda df: safe_get(df, "", "") + # }, + +##################################################################################################### +################################ General Information ################################ +##################################################################################################### + + { + "label": "Installation Name", + "key": "installation_name", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.Meta.description") + }, + { + "label": "Site Location", + "key": "site_location", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.Meta.address") + }, + { + "label": "Utility Name", + "key": "utility_name", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.utility") + }, + +##################################################################################################### +################################ Rate Analysis Summary ################################ +##################################################################################################### + + { + "label": "Rate Analysis Summary", + "key": "rate_analysis_summary_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Rate Name", + "key": "urdb_rate_name", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.rate_name") + }, + { + "label": "Voltage Level", + "key": "voltage_level", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.voltage_level") + }, + { + "label": "Year 1 Fixed Charges ($)", + "key": "year_1_fixed_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_fixed_cost_before_tax") + }, + { + "label": "Year 1 Energy Charges ($)", + "key": "year_1_energy_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_energy_cost_before_tax") + }, + { + "label": "Year 1 Demand Charges ($)", + "key": "year_1_demand_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_demand_cost_before_tax") + }, + { + "label": "Year 1 Total Bill Charges ($)", + "key": "year_1_total_bill_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_bill_before_tax") + }, + # this value will need to be calculated compared to the current rate + { + "label": "Change in Year 1 Total Charges ($)", + "key": "change_in_year_1_total_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + +##################################################################################################### +################################ Year 1 Annual Costs ################################ +##################################################################################################### + + { + "label": "Year 1 Annual Costs", + "key": "year_1_annual_costs_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Annual Fixed Charges ($)", + "key": "year_1_fixed_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_fixed_cost_before_tax") + }, + # this value will need to be calculated compared to the current rate + { + "label": "Year 1 Fixed Charges Percent Change (%)", + "key": "year_1_fixed_charges_percent_change", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Annual Energy Charges ($)", + "key": "year_1_energy_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_energy_cost_before_tax") + }, + # this value will need to be calculated compared to the current rate + { + "label": "Year 1 Energy Charges Percent Change (%)", + "key": "year_1_energy_charges_percent_change", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Annual Demand Charges ($)", + "key": "year_1_demand_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_demand_cost_before_tax") + }, + # this value will need to be calculated compared to the current rate + { + "label": "Year 1 Demand Charges Percent Change (%)", + "key": "year_1_demand_charges_percent_change", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Annual Total Bill Charges ($)", + "key": "year_1_total_bill_charges", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.year_one_bill_before_tax") + }, + # this value will need to be calculated compared to the current rate + { + "label": "Year 1 Total Bill Charges Percent Change (%)", + "key": "year_1_total_bill_charges_percent_change", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + +##################################################################################################### +################################ Year 1 Annual Costs as a Percent of Total Bill ################################ +##################################################################################################### + + { + "label": "Year 1 Annual Costs as a Percent of Total Bill", + "key": "year_1_annual_costs_percent_of_total_bill_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Fixed Charges Percent of Total Bill (%)", # value will be a calculation + "key": "year_1_fixed_charges_percent_of_total_bill", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Energy Charges Percent of Total Bill (%)", # value will be a calculation + "key": "year_1_energy_charges_percent_of_total_bill", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Year 1 Demand Charges Percent of Total Bill (%)", # value will be a calculation + "key": "year_1_demand_charges_percent_of_total_bill", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + +##################################################################################################### +################################ Load Metrics ################################ +##################################################################################################### + + { + "label": "Load Metrics", + "key": "year_1_load_metrics_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "Annual Grid Purchases (kWh)", + "key": "annual_grid_purchases_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.annual_calculated_kwh") + }, + { + "label": "Year 1 Peak Load (kW)", + "key": "year_1_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.annual_peak_kw") + }, + +##################################################################################################### +################################ URDB Rate Information ################################ +##################################################################################################### + { + "label": "URDB Rate Information", + "key": "urdb_rate_information_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "URDB Rate Name", + "key": "urdb_rate_name", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.rate_name") + }, + { + "label": "URDB Label", + "key": "urdb_label", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.label") + }, + { + "label": "URDB Rate Effective Date", + "key": "urdb_rate_effective_date", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.rate_effective_date") + }, + { + "label": "URDB Voltage Level", + "key": "urdb_voltage_level", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.voltage_level") + }, + { + "label": "URDB Peak kW Capacity Min", + "key": "urdb_peak_kw_capacity_min", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.peak_kw_capacity_min") + }, + { + "label": "URDB Peak kW Capacity Max", + "key": "urdb_peak_kw_capacity_max", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.peak_kw_capacity_max") + }, + { + "label": "URDB Rate Description", + "key": "urdb_rate_description", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.rate_description") + }, + { + "label": "URDB Additional Information", + "key": "urdb_additional_information", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.rate_additional_info") + }, + { + "label": "URDB Energy Comments", + "key": "urdb_energy_comments", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.energy_comments") + }, + { + "label": "URDB Demand Comments", + "key": "urdb_demand_comments", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "inputs.ElectricTariff.urdb_metadata.demand_comments") + }, + { + "label": "URDB URL", + "key": "urdb_url", + "bau_value": lambda df: "", + "scenario_value": lambda df: f'=HYPERLINK("{safe_get(df, "inputs.ElectricTariff.urdb_metadata.url_link")}", "URDB Link")' + }, + +##################################################################################################### +################################ Year 1 Monthly Energy Costs ################################ +##################################################################################################### + + { + "label": "Year 1 Monthly Energy Costs ($)", + "key": "year_1_monthly_energy_costs_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "January Energy Cost ($)", + "key": "january_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.0") # January + }, + { + "label": "February Energy Cost ($)", + "key": "february_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.1") + }, + { + "label": "March Energy Cost ($)", + "key": "march_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.2") + }, + { + "label": "April Energy Cost ($)", + "key": "april_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.3") + }, + { + "label": "May Energy Cost ($)", + "key": "may_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.4") + }, + { + "label": "June Energy Cost ($)", + "key": "june_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.5") + }, + { + "label": "July Energy Cost ($)", + "key": "july_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.6") + }, + { + "label": "August Energy Cost ($)", + "key": "august_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.7") + }, + { + "label": "September Energy Cost ($)", + "key": "september_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.8") + }, + { + "label": "October Energy Cost ($)", + "key": "october_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.9") + }, + { + "label": "November Energy Cost ($)", + "key": "november_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.10") + }, + { + "label": "December Energy Cost ($)", + "key": "december_energy_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.11") + }, + +##################################################################################################### +################################ Year 1 Monthly Demand Costs ################################ +##################################################################################################### + + { + "label": "Year 1 Monthly Demand Costs ($)", + "key": "year_1_monthly_demand_costs_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "January Demand Cost ($)", + "key": "january_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.0") + }, + { + "label": "February Demand Cost ($)", + "key": "february_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.1") + }, + { + "label": "March Demand Cost ($)", + "key": "march_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.2") + }, + { + "label": "April Demand Cost ($)", + "key": "april_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.3") + }, + { + "label": "May Demand Cost ($)", + "key": "may_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.4") + }, + { + "label": "June Demand Cost ($)", + "key": "june_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.5") + }, + { + "label": "July Demand Cost ($)", + "key": "july_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.6") + }, + { + "label": "August Demand Cost ($)", + "key": "august_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.7") + }, + { + "label": "September Demand Cost ($)", + "key": "september_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.8") + }, + { + "label": "October Demand Cost ($)", + "key": "october_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.9") + }, + { + "label": "November Demand Cost ($)", + "key": "november_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.10") + }, + { + "label": "December Demand Cost ($)", + "key": "december_demand_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.11") + }, + +##################################################################################################### +################################ Year 1 Monthly Total Bill Costs ################################ +##################################################################################################### + + { + "label": "Year 1 Monthly Total Bill Costs ($)", + "key": "year_1_monthly_total_bill_costs_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "January Total Bill Cost ($)", + "key": "january_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.0") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.0") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.0") + }, + { + "label": "February Total Bill Cost ($)", + "key": "february_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.1") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.1") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.1") + }, + { + "label": "March Total Bill Cost ($)", + "key": "march_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.2") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.2") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.2") + }, + { + "label": "April Total Bill Cost ($)", + "key": "april_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.3") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.3") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.3") + }, + { + "label": "May Total Bill Cost ($)", + "key": "may_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.4") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.4") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.4") + }, + { + "label": "June Total Bill Cost ($)", + "key": "june_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.5") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.5") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.5") + }, + { + "label": "July Total Bill Cost ($)", + "key": "july_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.6") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.6") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.6") + }, + { + "label": "August Total Bill Cost ($)", + "key": "august_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.7") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.7") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.7") + }, + { + "label": "September Total Bill Cost ($)", + "key": "september_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.8") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.8") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.8") + }, + { + "label": "October Total Bill Cost ($)", + "key": "october_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.9") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.9") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.9") + }, + { + "label": "November Total Bill Cost ($)", + "key": "november_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.10") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.10") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.10") + }, + { + "label": "December Total Bill Cost ($)", + "key": "december_total_bill_cost", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricTariff.monthly_fixed_cost.11") + safe_get(df, "outputs.ElectricTariff.monthly_energy_cost_series_before_tax.11") + safe_get(df, "outputs.ElectricTariff.monthly_demand_cost_series_before_tax.11") + }, + +##################################################################################################### +################################ Monthly Energy Consumption (kWh) ################################ +##################################################################################################### + + { + "label": "Monthly Energy Consumption (kWh)", + "key": "monthly_energy_consumption_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "January Energy Consumption (kWh)", + "key": "january_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.0") + }, + { + "label": "February Energy Consumption (kWh)", + "key": "february_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.1") + }, + { + "label": "March Energy Consumption (kWh)", + "key": "march_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.2") + }, + { + "label": "April Energy Consumption (kWh)", + "key": "april_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.3") + }, + { + "label": "May Energy Consumption (kWh)", + "key": "may_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.4") + }, + { + "label": "June Energy Consumption (kWh)", + "key": "june_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.5") + }, + { + "label": "July Energy Consumption (kWh)", + "key": "july_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.6") + }, + { + "label": "August Energy Consumption (kWh)", + "key": "august_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.7") + }, + { + "label": "September Energy Consumption (kWh)", + "key": "september_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.8") + }, + { + "label": "October Energy Consumption (kWh)", + "key": "october_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.9") + }, + { + "label": "November Energy Consumption (kWh)", + "key": "november_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.10") + }, + { + "label": "December Energy Consumption (kWh)", + "key": "december_energy_consumption_kwh", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_calculated_kwh.11") + }, + +##################################################################################################### +################################ Monthly Peak Load (kW) ################################ +##################################################################################################### + + { + "label": "Monthly Peak Load (kW)", + "key": "monthly_peak_load_separator", + "bau_value": lambda df: "", + "scenario_value": lambda df: "" + }, + { + "label": "January Peak Load (kW)", + "key": "january_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.0") + }, + { + "label": "February Peak Load (kW)", + "key": "february_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.1") + }, + { + "label": "March Peak Load (kW)", + "key": "march_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.2") + }, + { + "label": "April Peak Load (kW)", + "key": "april_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.3") + }, + { + "label": "May Peak Load (kW)", + "key": "may_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.4") + }, + { + "label": "June Peak Load (kW)", + "key": "june_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.5") + }, + { + "label": "July Peak Load (kW)", + "key": "july_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.6") + }, + { + "label": "August Peak Load (kW)", + "key": "august_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.7") + }, + { + "label": "September Peak Load (kW)", + "key": "september_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.8") + }, + { + "label": "October Peak Load (kW)", + "key": "october_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.9") + }, + { + "label": "November Peak Load (kW)", + "key": "november_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.10") + }, + { + "label": "December Peak Load (kW)", + "key": "december_peak_load_kw", + "bau_value": lambda df: "", + "scenario_value": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw.11") } -] \ No newline at end of file +] diff --git a/reoptjl/custom_table_helpers.py b/reoptjl/custom_table_helpers.py index 5e56a9ca9..68286fd3d 100644 --- a/reoptjl/custom_table_helpers.py +++ b/reoptjl/custom_table_helpers.py @@ -2,12 +2,22 @@ from typing import Dict, Any, List, Union def flatten_dict(d: Dict[str, Any], parent_key: str = '', sep: str = '.') -> Dict[str, Any]: - """Flatten nested dictionary.""" + """Flatten nested dictionary and handle arrays by creating indexed keys.""" items = [] for k, v in d.items(): new_key = f"{parent_key}{sep}{k}" if parent_key else k if isinstance(v, dict): items.extend(flatten_dict(v, new_key, sep=sep).items()) + elif isinstance(v, list): + # Handle arrays by creating indexed keys (e.g., key.0, key.1, key.2, ...) + for i, item in enumerate(v): + indexed_key = f"{new_key}{sep}{i}" + if isinstance(item, dict): + # If array item is a dict, flatten it further + items.extend(flatten_dict(item, indexed_key, sep=sep).items()) + else: + # If array item is a scalar, add it directly + items.append((indexed_key, item)) else: items.append((new_key, v)) return dict(items) @@ -20,12 +30,19 @@ def clean_data_dict(data_dict: Dict[str, List[Any]]) -> Dict[str, List[Any]]: for key, value_array in data_dict.items() } -def sum_vectors(data: Union[Dict[str, Any], List[Any]]) -> Union[Dict[str, Any], List[Any], Any]: - """Sum numerical vectors within a nested data structure.""" +def sum_vectors(data: Union[Dict[str, Any], List[Any]], preserve_monthly: bool = True) -> Union[Dict[str, Any], List[Any], Any]: + """Sum numerical vectors within a nested data structure, but preserve monthly arrays.""" if isinstance(data, dict): - return {key: sum_vectors(value) for key, value in data.items()} + result = {} + for key, value in data.items(): + # Preserve monthly series arrays - don't sum them + if preserve_monthly and 'monthly_' in key and isinstance(value, list): + result[key] = value # Keep monthly data as arrays + else: + result[key] = sum_vectors(value, preserve_monthly) + return result elif isinstance(data, list): - return sum(data) if all(isinstance(item, (int, float)) for item in data) else [sum_vectors(item) for item in data] + return sum(data) if all(isinstance(item, (int, float)) for item in data) else [sum_vectors(item, preserve_monthly) for item in data] else: return data @@ -40,4 +57,5 @@ def colnum_string(n: int) -> str: def safe_get(df: Dict[str, Any], key: str, default: Any = 0) -> Any: """Safely get a value from a dictionary with a default fallback.""" value = df.get(key, default if default is not None else 0) - return value if value is not None else 0 \ No newline at end of file + return value if value is not None else 0 + \ No newline at end of file diff --git a/reoptjl/migrations/0110_electricloadinputs_monthly_peaks_kw_and_more.py b/reoptjl/migrations/0110_electricloadinputs_monthly_peaks_kw_and_more.py new file mode 100644 index 000000000..542a1b752 --- /dev/null +++ b/reoptjl/migrations/0110_electricloadinputs_monthly_peaks_kw_and_more.py @@ -0,0 +1,155 @@ +# Generated by Django 4.2.26 on 2025-11-12 04:20 + +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0109_remove_ghpoutputs_iterations_auto_guess_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='electricloadinputs', + name='monthly_peaks_kw', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text="Monthly peak power consumption (an array 12 entries long), in kW, used to scale either loads_kw series (with normalize_and_scale_load_profile_input) or the simulated default building load profile for the site's climate zone.Monthly energy is maintained while scaling to the monthly peaks.", size=None), + ), + migrations.AddField( + model_name='electricloadoutputs', + name='annual_peak_kw', + field=models.FloatField(blank=True, help_text='Annual peak energy demand determined from load_series_kw. Does not include electric load for any new heating or cooling techs.', null=True), + ), + migrations.AddField( + model_name='electricloadoutputs', + name='monthly_calculated_kwh', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Monthly energy consumption calculated by summing up load_series_kw. Does not include electric load for any new heating or cooling techs.', size=None), + ), + migrations.AddField( + model_name='electricloadoutputs', + name='monthly_peaks_kw', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Monthly peak energy demand determined from load_series_kw. Does not include electric load for any new heating or cooling techs.', size=None), + ), + migrations.AddField( + model_name='electrictariffinputs', + name='urdb_metadata', + field=models.JSONField(blank=True, help_text='Utility rate meta data from Utility Rate Database API', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='demand_rate_average_series', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Series of average (across tiers) demand rates for each timestep in year one.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='energy_cost_series_before_tax', + field=models.JSONField(blank=True, help_text='Series of cost of power purchased from grid to serve load in each timestep, by Tier_i.', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='energy_cost_series_before_tax_bau', + field=models.JSONField(blank=True, help_text='Business as usual series of cost of power purchased from grid to serve load in each timestep, by Tier_i.', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='energy_rate_average_series', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Series of average (across tiers) energy rates for each timestep in year one.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='energy_rate_series', + field=models.JSONField(blank=True, help_text='Series of billed energy rates for each timestep in year one.', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='energy_rate_tier_limits', + field=models.JSONField(blank=True, help_text='Energy rate tier limits', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='facility_demand_monthly_rate_series', + field=models.JSONField(blank=True, help_text='Facility (not dependent on TOU) demand charge rates by Tier_i', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='facility_demand_monthly_rate_tier_limits', + field=models.JSONField(blank=True, help_text='Facility (not dependent on TOU) demand charge tier limits', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_demand_cost_series_before_tax', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Series of total (facility and TOU for all tiers) monthly demand charges for each month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_demand_cost_series_before_tax_bau', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Business as usual series of total (facility and TOU for all tiers) monthly demand charges for each month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_energy_cost_series_before_tax', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Series of monthly cost of power purchased from grid to serve loads.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_energy_cost_series_before_tax_bau', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Business as usual series of monthly cost of power purchased from grid to serve loads.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_facility_demand_cost_series_before_tax', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Series of total (all tiers) monthly facility demand charges by month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_facility_demand_cost_series_before_tax_bau', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Business as usual series of total (all tiers) monthly facility demand charges by month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_fixed_cost_series_before_tax', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Year one fixed utility costs for each month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_fixed_cost_series_before_tax_bau', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Business as usual year one fixed utility costs for each month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_tou_demand_cost_series_before_tax', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Series of total time-of-use demand charges for each month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='monthly_tou_demand_cost_series_before_tax_bau', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True), default=list, help_text='Business as usual series of total time-of-use demand charges for each month.', size=None), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='tou_demand_metrics', + field=models.JSONField(blank=True, help_text='Dictionary of TOU demand metrics, including month, tier, demand_rate, measured_tou_peak_demand, and demand_charge_before_tax', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='tou_demand_rate_series', + field=models.JSONField(blank=True, help_text='Series of demand rates by Tier_i for each timestep.', null=True), + ), + migrations.AddField( + model_name='electrictariffoutputs', + name='tou_demand_rate_tier_limits', + field=models.JSONField(blank=True, help_text='TOU demand rate tier limits', null=True), + ), + migrations.AlterField( + model_name='electricloadinputs', + name='monthly_totals_kwh', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text="Monthly site energy consumption (an array 12 entries long), in kWh, used to scale either loads_kw series (with normalize_and_scale_load_profile_input) or the simulated default building load profile for the site's climate zone", size=None), + ), + migrations.AlterField( + model_name='electricloadinputs', + name='normalize_and_scale_load_profile_input', + field=models.BooleanField(blank=True, default=False, help_text='Takes the input loads_kw and normalizes and scales it to the inputs annual_kwh, monthly_totals_kwh, and/or monthly_peaks_kw.'), + ), + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index d535fe252..4d134884e 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -1387,8 +1387,22 @@ class ElectricLoadInputs(BaseModel, models.Model): blank=True ), default=list, blank=True, - help_text=("Monthly site energy consumption from electricity series (an array 12 entries long), in kWh, used " - "to scale simulated default building load profile for the site's climate zone") + help_text=("Monthly site energy consumption (an array 12 entries long), in kWh, used to scale either loads_kw series " + "(with normalize_and_scale_load_profile_input) or the simulated default building load profile for the site's climate zone") + ) + monthly_peaks_kw = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(1.0e8) + ], + blank=True + ), + default=list, blank=True, + help_text=("Monthly peak power consumption (an array 12 entries long), in kW, used to scale either loads_kw series " + "(with normalize_and_scale_load_profile_input) or the simulated default building load profile for the site's climate zone." + "Monthly energy is maintained while scaling to the monthly peaks." + ) ) loads_kw = ArrayField( models.FloatField(blank=True), @@ -1401,7 +1415,7 @@ class ElectricLoadInputs(BaseModel, models.Model): normalize_and_scale_load_profile_input = models.BooleanField( blank=True, default=False, - help_text=("Takes the input loads_kw and normalizes and scales it to annual or monthly energy inputs.") + help_text=("Takes the input loads_kw and normalizes and scales it to the inputs annual_kwh, monthly_totals_kwh, and/or monthly_peaks_kw.") ) critical_loads_kw = ArrayField( models.FloatField(blank=True), @@ -1546,6 +1560,24 @@ class ElectricLoadOutputs(BaseModel, models.Model): null=True, blank=True, help_text="Annual energy consumption calculated by summing up load_series_kw. Does not include electric load for any new heating or cooling techs." ) + monthly_calculated_kwh = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Monthly energy consumption calculated by summing up load_series_kw. Does not include electric load for any new heating or cooling techs." + ) + monthly_peaks_kw = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Monthly peak energy demand determined from load_series_kw. Does not include electric load for any new heating or cooling techs." + ) + annual_peak_kw = models.FloatField( + null=True, blank=True, + help_text="Annual peak energy demand determined from load_series_kw. Does not include electric load for any new heating or cooling techs." + ) annual_electric_load_with_thermal_conversions_kwh = models.FloatField( null=True, blank=True, help_text="Total end-use electrical load, including electrified heating and cooling end-use load." @@ -1720,6 +1752,10 @@ class ElectricTariffInputs(BaseModel, models.Model): help_text=("Optional coincident peak demand charge that is applied to the max load during the time_steps " "specified in coincident_peak_load_active_time_steps") ) + urdb_metadata = models.JSONField( + null=True, blank=True, + help_text=("Utility rate meta data from Utility Rate Database API") + ) def clean(self): error_messages = {} @@ -2603,6 +2639,127 @@ class ElectricTariffOutputs(BaseModel, models.Model): related_name="ElectricTariffOutputs", primary_key=True ) + + monthly_fixed_cost_series_before_tax = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Year one fixed utility costs for each month." + ) + monthly_fixed_cost_series_before_tax_bau = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Business as usual year one fixed utility costs for each month." + ) + energy_cost_series_before_tax = models.JSONField( + null=True, blank=True, + help_text="Series of cost of power purchased from grid to serve load in each timestep, by Tier_i." + ) + energy_cost_series_before_tax_bau = models.JSONField( + null=True, blank=True, + help_text="Business as usual series of cost of power purchased from grid to serve load in each timestep, by Tier_i." + ) + monthly_energy_cost_series_before_tax = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Series of monthly cost of power purchased from grid to serve loads." + ) + monthly_energy_cost_series_before_tax_bau = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Business as usual series of monthly cost of power purchased from grid to serve loads." + ) + monthly_facility_demand_cost_series_before_tax = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Series of total (all tiers) monthly facility demand charges by month." + ) + monthly_facility_demand_cost_series_before_tax_bau = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Business as usual series of total (all tiers) monthly facility demand charges by month." + ) + energy_rate_series = models.JSONField( + null=True, blank=True, + help_text="Series of billed energy rates for each timestep in year one." + ) + energy_rate_average_series = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Series of average (across tiers) energy rates for each timestep in year one." + ) + monthly_tou_demand_cost_series_before_tax = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Series of total time-of-use demand charges for each month." + ) + monthly_tou_demand_cost_series_before_tax_bau = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Business as usual series of total time-of-use demand charges for each month." + ) + monthly_demand_cost_series_before_tax = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Series of total (facility and TOU for all tiers) monthly demand charges for each month." + ) + monthly_demand_cost_series_before_tax_bau = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Business as usual series of total (facility and TOU for all tiers) monthly demand charges for each month." + ) + tou_demand_metrics = models.JSONField( + null=True, blank=True, + help_text="Dictionary of TOU demand metrics, including month, tier, demand_rate, measured_tou_peak_demand, and demand_charge_before_tax" + ) + facility_demand_monthly_rate_tier_limits = models.JSONField( + null=True, blank=True, + help_text="Facility (not dependent on TOU) demand charge tier limits" + ) + facility_demand_monthly_rate_series = models.JSONField( + null=True, blank=True, + help_text="Facility (not dependent on TOU) demand charge rates by Tier_i" + ) + tou_demand_rate_tier_limits = models.JSONField( + null=True, blank=True, + help_text="TOU demand rate tier limits" + ) + energy_rate_tier_limits = models.JSONField( + null=True, blank=True, + help_text="Energy rate tier limits" + ) + tou_demand_rate_series = models.JSONField( + null=True, blank=True, + help_text="Series of demand rates by Tier_i for each timestep." + ) + demand_rate_average_series = ArrayField( + models.FloatField( + null=True, blank=True + ), + default=list, + help_text="Series of average (across tiers) demand rates for each timestep in year one." + ) year_one_energy_cost_before_tax = models.FloatField( null=True, blank=True, help_text="Optimal year one utility energy cost" diff --git a/reoptjl/src/process_results.py b/reoptjl/src/process_results.py index 20b26e209..22bf0a292 100644 --- a/reoptjl/src/process_results.py +++ b/reoptjl/src/process_results.py @@ -9,7 +9,7 @@ SteamTurbineOutputs, GHPInputs, GHPOutputs, ExistingChillerInputs, \ ElectricHeaterOutputs, ASHPSpaceHeaterOutputs, ASHPWaterHeaterOutputs, \ SiteInputs, ASHPSpaceHeaterInputs, ASHPWaterHeaterInputs, CSTInputs, CSTOutputs, PVInputs, \ - HighTempThermalStorageInputs, HighTempThermalStorageOutputs + HighTempThermalStorageInputs, HighTempThermalStorageOutputs, ElectricTariffInputs import numpy as np import sys import traceback as tb @@ -179,6 +179,9 @@ def update_inputs_in_database(inputs_to_update: dict, run_uuid: str) -> None: if inputs_to_update.get("HighTempThermalStorage") is not None: prune_update_fields(HighTempThermalStorageInputs, inputs_to_update["HighTempThermalStorage"]) HighTempThermalStorageInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["HighTempThermalStorage"]) + if inputs_to_update.get("ElectricTariff"): + prune_update_fields(ElectricTariffInputs, inputs_to_update["ElectricTariff"]) + ElectricTariffInputs.objects.filter(meta__run_uuid=run_uuid).update(**inputs_to_update["ElectricTariff"]) except Exception as e: exc_type, exc_value, exc_traceback = sys.exc_info() debug_msg = "exc_type: {}; exc_value: {}; exc_traceback: {}".format( diff --git a/reoptjl/test/posts/all_inputs_test.json b/reoptjl/test/posts/all_inputs_test.json index 8381b01b5..a2355ef95 100644 --- a/reoptjl/test/posts/all_inputs_test.json +++ b/reoptjl/test/posts/all_inputs_test.json @@ -35,6 +35,7 @@ "doe_reference_name": "MidriseApartment", "year": 2017, "monthly_totals_kwh": [], + "monthly_peaks_kw": [], "loads_kw": [], "critical_loads_kw": [], "loads_kw_is_net": true, diff --git a/reoptjl/test/posts/sector_defaults_post.json b/reoptjl/test/posts/sector_defaults_post.json index 1df5abbae..ca174468f 100644 --- a/reoptjl/test/posts/sector_defaults_post.json +++ b/reoptjl/test/posts/sector_defaults_post.json @@ -23,44 +23,24 @@ "ExistingBoiler": { "fuel_cost_per_mmbtu": 10.0 }, - "CHP": { - "prime_mover": "recip_engine", - "max_kw": 100, - "fuel_cost_per_mmbtu": 10.0, - "can_supply_steam_turbine": true - }, "PV": { "min_kw": 1000.0, "max_kw": 1000.0, "federal_itc_fraction": 0.2 }, - "SteamTurbine":{ - "min_kw": 100, - "max_kw": 100 - }, - "HighTempThermalStorage": { - "min_kwh": 10, - "max_kwh": 10, - "thermal_decay_rate_fraction": 0.0 - }, "Wind": { "min_kw": 100, "max_kw": 100 }, - "GHP": { - "require_ghp_purchase": true, - "building_sqft": 50000.0, - "can_serve_dhw": false, - "space_heating_efficiency_thermal_factor": 0.85, - "cooling_efficiency_thermal_factor": 0.6, - "ghpghx_inputs": [{ - "borehole_depth_ft": 400.0, - "simulation_years": 20, - "solver_eft_tolerance_f": 2.0, - "ghx_model": "TESS", - "tess_ghx_minimum_timesteps_per_hour": 1, - "max_sizing_iterations": 10, - "init_sizing_factor_ft_per_peak_ton": 300.0 - }] + "ElectricStorage": { + "min_kw": 50, + "max_kw": 50, + "min_kwh": 100, + "max_kwh": 100 + }, + "Boiler": { + "fuel_cost_per_mmbtu": 10.0, + "min_mmbtu_per_hour": 0.5, + "max_mmbtu_per_hour": 0.5 } } \ No newline at end of file diff --git a/reoptjl/test/posts/validator_post.json b/reoptjl/test/posts/validator_post.json index d4c8ce524..05f4e78ab 100644 --- a/reoptjl/test/posts/validator_post.json +++ b/reoptjl/test/posts/validator_post.json @@ -44,6 +44,20 @@ 1200, 1200 ], + "monthly_peaks_kw": [ + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5 + ], "outage_start_time_step": 11, "outage_end_time_step": 501, "critical_load_fraction": 0.5, diff --git a/reoptjl/test/test_job_endpoint.py b/reoptjl/test/test_job_endpoint.py index 6f74663aa..f880ba921 100644 --- a/reoptjl/test/test_job_endpoint.py +++ b/reoptjl/test/test_job_endpoint.py @@ -178,8 +178,6 @@ def test_sector_defaults_from_julia(self): self.assertEqual(saved_model_inputs.get(input_key), post[model_name][input_key]) else: # Check that default got assigned consistent with /sector_defaults - if model_name == "SteamTurbine" and input_key == "federal_itc_fraction": - continue #ST doesn't have federal_itc_fraction input self.assertEqual(saved_model_inputs.get(input_key), default_input_val) def test_chp_defaults_from_julia(self): diff --git a/reoptjl/urls.py b/reoptjl/urls.py index eec71fae9..16e40949e 100644 --- a/reoptjl/urls.py +++ b/reoptjl/urls.py @@ -28,5 +28,6 @@ re_path(r'^get_ashp_defaults/?$', views.get_ashp_defaults), re_path(r'^pv_cost_defaults/?$', views.pv_cost_defaults), re_path(r'^summary_by_runuuids/?$', views.summary_by_runuuids), - re_path(r'^link_run_to_portfolios/?$', views.link_run_uuids_to_portfolio_uuid) + re_path(r'^link_run_to_portfolios/?$', views.link_run_uuids_to_portfolio_uuid), + re_path(r'^get_load_metrics/?$', views.get_load_metrics) ] diff --git a/reoptjl/views.py b/reoptjl/views.py index 38556999b..ba3a3ab30 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -582,10 +582,10 @@ def simulated_load(request): # Required for GET - will throw a Missing Error if not included if request.method == "GET": valid_keys = ["doe_reference_name","industrial_reference_name","latitude","longitude","load_type","percent_share","annual_kwh", - "monthly_totals_kwh","annual_mmbtu","annual_fraction","annual_tonhour","monthly_tonhour", + "monthly_totals_kwh","monthly_peaks_kw","annual_mmbtu","annual_fraction","annual_tonhour","monthly_tonhour", "monthly_mmbtu","monthly_fraction","max_thermal_factor_on_peak_load","chiller_cop", "addressable_load_fraction", "cooling_doe_ref_name", "cooling_pct_share", "boiler_efficiency", - "normalize_and_scale_load_profile_input", "year"] + "normalize_and_scale_load_profile_input", "year", "time_steps_per_hour"] for key in request.GET.keys(): k = key if "[" in key: @@ -653,12 +653,30 @@ def simulated_load(request): if request.method == "POST": data = json.loads(request.body) - required_post_fields = ["load_type", "normalize_and_scale_load_profile_input", "load_profile", "year"] + required_post_fields = ["load_type", "year"] + either_required = ["normalize_and_scale_load_profile_input", "doe_reference_name"] + either_check = 0 + for either in either_required: + if data.get(either) is not None: + inputs[either] = data[either] + either_check += 1 + if either_check == 0: + return JsonResponse({"Error": "Missing either of normalize_and_scale_load_profile_input or doe_reference_name."}, status=400) + elif either_check == 2: + return JsonResponse({"Error": "Both normalize_and_scale_load_profile_input and doe_reference_name were input; only input one of these."}, status=400) for field in required_post_fields: - # TODO make year optional? + # TODO make year optional for doe_reference_name input inputs[field] = data[field] + if data.get("normalize_and_scale_load_profile_input") is not None: + if "load_profile" not in data: + return JsonResponse({"Error": "load_profile is required when normalize_and_scale_load_profile_input is provided."}, status=400) + inputs["load_profile"] = data["load_profile"] + if len(inputs["load_profile"]) != 8760: + if "time_steps_per_hour" not in data: + return JsonResponse({"Error": "time_steps_per_hour is required when load_profile length is not 8760."}, status=400) + inputs["time_steps_per_hour"] = data["time_steps_per_hour"] if inputs["load_type"] == "electric": - for energy in ["annual_kwh", "monthly_totals_kwh"]: + for energy in ["annual_kwh", "monthly_totals_kwh", "monthly_peaks_kw"]: if data.get(energy) is not None: inputs[energy] = data.get(energy) elif inputs["load_type"] in ["space_heating", "domestic_hot_water", "process_heat"]: @@ -669,14 +687,19 @@ def simulated_load(request): for energy in ["annual_tonhour", "monthly_tonhour"]: if data.get(energy) is not None: inputs[energy] = data.get(energy) - # TODO cooling, not in REopt.jl yet - # TODO consider changing all requests to POST so that we don't have to do the weird array processing like percent_share[0], [1], etc? # json.dump(inputs, open("sim_load_post.json", "w")) julia_host = os.environ.get('JULIA_HOST', "julia") http_jl_response = requests.get("http://" + julia_host + ":8081/simulated_load/", json=inputs) + response_data = http_jl_response.json() + # Round all scalar/monthly outputs to 2 decimal places + load_profile_key = next((k for k in response_data if "loads_" in k), None) + load_profile = response_data.pop(load_profile_key) + rounded_response_data = round_values(response_data) + rounded_response_data[load_profile_key] = load_profile + response = JsonResponse( - http_jl_response.json(), + rounded_response_data, status=http_jl_response.status_code ) @@ -1647,6 +1670,63 @@ def easiur_costs(request): log.error(debug_msg) return JsonResponse({"Error": "Unexpected Error. Please check your input parameters and contact reopt@nrel.gov if problems persist."}, status=500) +def get_load_metrics(request): + try: + if request.method == "POST": + post_body = json.loads(request.body) + load_profile = list(post_body.get("load_profile")) + + inputs = { + "load_profile": load_profile + } + + # Add optional parameters if provided + if post_body.get("time_steps_per_hour") is not None: + inputs["time_steps_per_hour"] = int(post_body.get("time_steps_per_hour")) + + if post_body.get("year") is not None: + inputs["year"] = int(post_body.get("year")) + + julia_host = os.environ.get('JULIA_HOST', "julia") + http_jl_response = requests.get("http://" + julia_host + ":8081/get_load_metrics/", json=inputs) + response_data = http_jl_response.json() + + rounded_response_data = round_values(response_data) + + response = JsonResponse( + rounded_response_data, + status=http_jl_response.status_code + ) + return response + else: + return JsonResponse({"Error": "Only POST method is supported for this endpoint"}, status=405) + + except ValueError as e: + return JsonResponse({"Error": str(e.args[0])}, status=400) + + except KeyError as e: + return JsonResponse({"Error": str(e.args[0])}, status=400) + + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + debug_msg = "exc_type: {}; exc_value: {}; exc_traceback: {}".format(exc_type, exc_value.args[0], + tb.format_tb(exc_traceback)) + log.debug(debug_msg) + return JsonResponse({"Error": "Unexpected error in get_load_metrics endpoint. Check log for more."}, status=500) + +# Round all numeric values in the response +def round_values(obj): + if isinstance(obj, dict): + return {key: round_values(value) for key, value in obj.items()} + elif isinstance(obj, list): + return [round_values(item) for item in obj] + elif isinstance(obj, float): + return round(obj, 2) # Round to two decimal places + elif isinstance(obj, int): + return obj + else: + return obj + def sector_defaults(request): try: inputs = { @@ -1747,17 +1827,25 @@ def summarize_vector_data(request: Any, run_uuid: str) -> Dict[str, Any]: try: response = results(request, run_uuid) if response.status_code == 200: - return sum_vectors(json.loads(response.content)) + return sum_vectors(json.loads(response.content), preserve_monthly=True) return {"error": f"Failed to fetch data for run_uuid {run_uuid}"} except Exception: log_and_raise_error('summarize_vector_data') -def generate_data_dict(config: List[Dict[str, Any]], df_gen: Dict[str, Any]) -> Dict[str, List[Any]]: +def generate_data_dict(config: List[Dict[str, Any]], df_gen: Dict[str, Any]) -> Dict[str, Any]: + """ + Generates a dictionary mapping labels to single values (not lists). + Args: + config: List of configuration dictionaries, each with a "label" and "scenario_value" callable. + df_gen: Dictionary of scenario data. + Returns: + Dict[str, Any]: Dictionary mapping labels to single values. + """ try: - data_dict = defaultdict(list) + data_dict = {} for entry in config: val = entry["scenario_value"](df_gen) - data_dict[entry["label"]].append(val) + data_dict[entry["label"]] = val return data_dict except Exception: log_and_raise_error('generate_data_dict') @@ -1767,9 +1855,11 @@ def generate_reopt_dataframe(data_f: Dict[str, Any], scenario_name: str, config: scenario_name_str = str(scenario_name) df_gen = flatten_dict(data_f) data_dict = generate_data_dict(config, df_gen) - data_dict["Scenario"] = [scenario_name_str] + data_dict["Scenario"] = scenario_name_str col_order = ["Scenario"] + [entry["label"] for entry in config] - return pd.DataFrame(data_dict)[col_order] + # Convert to single-row DataFrame by wrapping each value in a list + df_data = {key: [value] for key, value in data_dict.items()} + return pd.DataFrame(df_data)[col_order] except Exception: log_and_raise_error('generate_reopt_dataframe') @@ -1799,21 +1889,36 @@ def get_bau_values(scenarios: List[Dict[str, Any]], config: List[Dict[str, Any]] def process_scenarios(scenarios: List[Dict[str, Any]], reopt_data_config: List[Dict[str, Any]]) -> pd.DataFrame: try: - bau_values_per_scenario = get_bau_values(scenarios, reopt_data_config) - combined_df = pd.DataFrame() + # Check if we're using custom_table_rates - if so, skip BAU data entirely + is_rates_table = (reopt_data_config == custom_table_rates) + + if is_rates_table: + # For custom_table_rates, only generate scenario data (no BAU) + all_dataframes = [] + for idx, scenario in enumerate(scenarios): + run_uuid = scenario['run_uuid'] + df_result = generate_reopt_dataframe(scenario['full_data'], run_uuid, reopt_data_config) + df_result["Scenario"] = run_uuid + all_dataframes.append(df_result) + else: + # For all other tables, generate both BAU and scenario data + bau_values_per_scenario = get_bau_values(scenarios, reopt_data_config) + all_dataframes = [] - for idx, scenario in enumerate(scenarios): - run_uuid = scenario['run_uuid'] - df_result = generate_reopt_dataframe(scenario['full_data'], run_uuid, reopt_data_config) - df_result["Scenario"] = run_uuid + for idx, scenario in enumerate(scenarios): + run_uuid = scenario['run_uuid'] + df_result = generate_reopt_dataframe(scenario['full_data'], run_uuid, reopt_data_config) + df_result["Scenario"] = run_uuid - bau_data = {key: [value] for key, value in bau_values_per_scenario[run_uuid].items()} - bau_data["Scenario"] = [f"BAU {idx + 1}"] - df_bau = pd.DataFrame(bau_data) + bau_data = {key: [value] for key, value in bau_values_per_scenario[run_uuid].items()} + bau_data["Scenario"] = [f"BAU {idx + 1}"] + df_bau = pd.DataFrame(bau_data) - combined_df = pd.concat([combined_df, df_bau, df_result], axis=0) if not combined_df.empty else pd.concat([df_bau, df_result], axis=0) + # Add both BAU and result dataframes to list + all_dataframes.extend([df_bau, df_result]) - combined_df.reset_index(drop=True, inplace=True) + # Concatenate all dataframes at once + combined_df = pd.concat(all_dataframes, axis=0, ignore_index=True) combined_df = pd.DataFrame(clean_data_dict(combined_df.to_dict(orient="list"))) return combined_df[["Scenario"] + [col for col in combined_df.columns if col != "Scenario"]] except Exception: @@ -1846,7 +1951,7 @@ def generate_results_table(request: Any) -> HttpResponse: final_df_transpose = final_df_transpose.drop(final_df_transpose.index[0]) output = io.BytesIO() - generate_excel_workbook(final_df_transpose, target_custom_table, output) + generate_excel_workbook(final_df_transpose, target_custom_table, output, scenarios['scenarios']) output.seek(0) response = HttpResponse(output, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') @@ -1858,35 +1963,39 @@ def generate_results_table(request: Any) -> HttpResponse: log.error(f"Unexpected error in generate_results_table: {e}") return JsonResponse({"Error": "An unexpected error occurred. Please try again later."}, status=500) -def generate_excel_workbook(df: pd.DataFrame, custom_table: List[Dict[str, Any]], output: io.BytesIO) -> None: +def generate_excel_workbook(df: pd.DataFrame, custom_table: List[Dict[str, Any]], output: io.BytesIO, scenarios: List[Dict[str, Any]] = None) -> None: try: workbook = xlsxwriter.Workbook(output, {'in_memory': True}) # Add the 'Results Table' worksheet worksheet = workbook.add_worksheet('Results Table') - # Add the 'Instructions' worksheet - instructions_worksheet = workbook.add_worksheet('Instructions') + # Check if using custom_table_rates + is_rates_table = (custom_table == custom_table_rates) + + # Add the 'Instructions' worksheet only for non-rates tables + if not is_rates_table: + instructions_worksheet = workbook.add_worksheet('Instructions') # Scenario header formatting with colors scenario_colors = ['#0B5E90', '#00A4E4','#f46d43','#fdae61', '#66c2a5', '#d53e4f', '#3288bd'] - scenario_formats = [workbook.add_format({'bold': True, 'bg_color': color, 'border': 1, 'align': 'center', 'font_color': 'white', 'font_size': 12}) for color in scenario_colors] + scenario_formats = [workbook.add_format({'bold': True, 'bg_color': color, 'border': 1, 'align': 'center', 'font_color': 'white', 'font_size': 12, 'text_wrap': True}) for color in scenario_colors] # Row alternating colors row_colors = ['#d1d5d8', '#fafbfb'] # Base formats for errors, percentages, and currency values - error_format = workbook.add_format({'bg_color': '#FFC7CE', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': 'white', 'bold': True, 'font_size': 10}) - base_percent_format = {'num_format': '0.0%', 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10} - base_currency_format = {'num_format': '$#,##0', 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10} + error_format = workbook.add_format({'bg_color': '#FFC7CE', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': 'white', 'bold': True, 'font_size': 10, 'text_wrap': True}) + base_percent_format = {'num_format': '0.0%', 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10, 'text_wrap': True} + base_currency_format = {'num_format': '$#,##0', 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10, 'text_wrap': True} # Formula formats using dark blue background formula_color = '#F8F8FF' - formula_format = workbook.add_format({'num_format': '#,##0','bg_color': '#0B5E90', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True}) - formula_payback_format = workbook.add_format({'num_format': '0.0','bg_color': '#0B5E90', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True}) - formula_percent_format = workbook.add_format({'bg_color': '#0B5E90', 'num_format': '0.0%', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True}) - formula_currency_format = workbook.add_format({'bg_color': '#0B5E90', 'num_format': '$#,##0', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True}) + formula_format = workbook.add_format({'num_format': '#,##0','bg_color': '#0B5E90', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True, 'text_wrap': True}) + formula_payback_format = workbook.add_format({'num_format': '0.0','bg_color': '#0B5E90', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True, 'text_wrap': True}) + formula_percent_format = workbook.add_format({'bg_color': '#0B5E90', 'num_format': '0.0%', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True, 'text_wrap': True}) + formula_currency_format = workbook.add_format({'bg_color': '#0B5E90', 'num_format': '$#,##0', 'align': 'center', 'valign': 'center', 'border': 1, 'font_color': formula_color, 'font_size': 10, 'italic': True, 'text_wrap': True}) # Message format for formula cells (blue background with white text) formula_message_format = workbook.add_format({ @@ -1897,7 +2006,8 @@ def generate_excel_workbook(df: pd.DataFrame, custom_table: List[Dict[str, Any]] 'border': 1, 'bold': True, 'font_size': 12, - 'italic': True + 'italic': True, + 'text_wrap': True }) # Message format for input cells (yellow background) @@ -1907,13 +2017,14 @@ def generate_excel_workbook(df: pd.DataFrame, custom_table: List[Dict[str, Any]] 'valign': 'center', 'border': 1, 'bold': True, - 'font_size': 12 + 'font_size': 12, + 'text_wrap': True }) # Separator format for rows that act as visual dividers - separator_format = workbook.add_format({'bg_color': '#5D6A71', 'bold': True, 'border': 1,'font_size': 11,'font_color': 'white'}) + separator_format = workbook.add_format({'bg_color': '#5D6A71', 'bold': True, 'border': 1,'font_size': 11,'font_color': 'white', 'text_wrap': True}) - input_cell_format = workbook.add_format({'bg_color': '#FFFC79', 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10}) + input_cell_format = workbook.add_format({'bg_color': '#FFFC79', 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10, 'text_wrap': True}) # Combine row color with cell format, excluding formulas def get_combined_format(label, row_color, is_formula=False): @@ -1925,9 +2036,9 @@ def get_combined_format(label, row_color, is_formula=False): elif 'yrs' in label: return formula_payback_format return formula_format - base_data_format = {'num_format': '#,##0','bg_color': row_color, 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10} - payback_data_format = {'num_format': '0.0','bg_color': row_color, 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10} - blue_text_format = {'font_color': 'blue', 'bg_color': row_color, 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10} + base_data_format = {'num_format': '#,##0','bg_color': row_color, 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10, 'text_wrap': True} + payback_data_format = {'num_format': '0.0','bg_color': row_color, 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10, 'text_wrap': True} + blue_text_format = {'font_color': 'blue', 'bg_color': row_color, 'align': 'center', 'valign': 'center', 'border': 1, 'font_size': 10, 'text_wrap': True} if label: if '$' in label: return workbook.add_format({**base_currency_format, 'bg_color': row_color}) @@ -1946,45 +2057,73 @@ def get_combined_format(label, row_color, is_formula=False): column_width = 25 columns_to_hide = set() - # Loop through BAU columns and check if all numerical values are identical across all BAU columns - bau_columns = [i for i, header in enumerate(df.columns) if "BAU" in header] - - # Only proceed if there are BAU columns - if bau_columns: - identical_bau_columns = True # Assume all BAU columns are identical unless proven otherwise - - # Loop through each row and check the values across BAU columns - for row_num in range(len(df)): - row_values = df.iloc[row_num, bau_columns].values # Get all BAU values for this row - - # Filter only numerical values for comparison - numerical_values = [value for value in row_values if isinstance(value, (int, float))] - - # Check if all numerical BAU values in this row are the same - if numerical_values: # Proceed only if there are numerical values to compare - first_bau_value = numerical_values[0] - if not all(value == first_bau_value for value in numerical_values): - identical_bau_columns = False - break # If any row has different BAU values, stop checking further - - # If all BAU columns are identical across all rows, hide all but the first BAU column - if identical_bau_columns: - for col_num in bau_columns[1:]: - columns_to_hide.add(col_num) - - # Now set the column properties for hiding BAU columns and leaving others unchanged + # Check if using custom_table_rates + is_rates_table = (custom_table == custom_table_rates) + + # Extract rate names for rates table headers + rate_names = [] + if is_rates_table and scenarios: + for scenario in scenarios: + rate_name = scenario.get('full_data', {}).get('inputs', {}).get('ElectricTariff', {}).get('urdb_metadata', {}).get('rate_name', None) + if rate_name: + rate_names.append(rate_name) + + # For non-rates tables, handle BAU column logic + if not is_rates_table: + # Loop through BAU columns and check if all numerical values are identical across all BAU columns + bau_columns = [i for i, header in enumerate(df.columns) if "BAU" in header] + + # Only proceed if there are BAU columns + if bau_columns: + identical_bau_columns = True # Assume all BAU columns are identical unless proven otherwise + + # Loop through each row and check the values across BAU columns + for row_num in range(len(df)): + row_values = df.iloc[row_num, bau_columns].values # Get all BAU values for this row + + # Filter only numerical values for comparison + numerical_values = [value for value in row_values if isinstance(value, (int, float))] + + # Check if all numerical BAU values in this row are the same + if numerical_values: # Proceed only if there are numerical values to compare + first_bau_value = numerical_values[0] + if not all(value == first_bau_value for value in numerical_values): + identical_bau_columns = False + break # If any row has different BAU values, stop checking further + + # If all BAU columns are identical across all rows, hide all but the first BAU column + if identical_bau_columns: + for col_num in bau_columns[1:]: + columns_to_hide.add(col_num) + + # Now set the column properties - remove BAU columns for rates table for col_num, header in enumerate(df.columns): - if "BAU" in header and col_num in columns_to_hide: - # Hide the BAU columns that have been marked + if not is_rates_table and "BAU" in header and col_num in columns_to_hide: + # Remove the BAU columns that have been marked (only for non-rates tables) worksheet.set_column(col_num + 1, col_num + 1, column_width, None, {'hidden': True}) else: - # Set the normal column width for non-hidden columns + # Set the normal column width for all columns (rates table has no BAU columns to hide) worksheet.set_column(col_num + 1, col_num + 1, column_width) # Write scenario headers worksheet.write('A1', 'Scenario', scenario_formats[0]) + + # Track non-BAU column index for rate name mapping (only for custom_table_rates) + non_bau_index = 0 + for col_num, header in enumerate(df.columns): - worksheet.write(0, col_num + 1, header, scenario_formats[(col_num // 2) % (len(scenario_formats) - 1) + 1]) + # For custom_table_rates, use rate names for column headers + if is_rates_table and rate_names: + # For rates table, all columns are scenario columns - use rate names + if non_bau_index < len(rate_names): + header_text = rate_names[non_bau_index] + else: + header_text = header + non_bau_index += 1 + else: + header_text = header + + worksheet.write(0, col_num + 1, header_text, scenario_formats[(col_num // 2) % (len(scenario_formats) - 1) + 1]) # Write variable names and data with full-row formatting row_offset = 0 # To keep track of the current row in the worksheet @@ -2000,7 +2139,7 @@ def get_combined_format(label, row_color, is_formula=False): row_color = row_colors[(row_num + row_offset) % 2] # Alternating row colors # Write the label in the first column - worksheet.write(row_num + 1 + row_offset, 0, entry['label'], workbook.add_format({'bg_color': row_color, 'border': 1})) + worksheet.write(row_num + 1 + row_offset, 0, entry['label'], workbook.add_format({'bg_color': row_color, 'border': 1, 'text_wrap': True})) # Write the data for each column variable = entry['label'] # Assuming df index or columns match the label @@ -2054,15 +2193,21 @@ def get_bau_column(col): missing_entries = [] for col in range(2, len(df.columns) + 2): - # Skip BAU columns (BAU columns should not have formulas) - if col % 2 == 0: - continue # Skip the BAU column + # For rates table, apply formulas to all columns since there are no BAU columns + # For other tables, skip BAU columns (every other column) + if not is_rates_table and col % 2 == 0: + continue # Skip the BAU column for non-rates tables col_letter = colnum_string(col) - bau_col = get_bau_column(col) # Get the corresponding BAU column - bau_col_letter = colnum_string(bau_col) # Convert the column number to letter for Excel reference - - bau_cells = {cell_name: f'{bau_col_letter}{headers[header] + 2}' for cell_name, header in bau_cells_config.items() if header in headers} + + # For non-rates tables, get the corresponding BAU column + if not is_rates_table: + bau_col = get_bau_column(col) # Get the corresponding BAU column + bau_col_letter = colnum_string(bau_col) # Convert the column number to letter for Excel reference + bau_cells = {cell_name: f'{bau_col_letter}{headers[header] + 2}' for cell_name, header in bau_cells_config.items() if header in headers} + else: + # For rates table, no BAU cells since we don't have BAU columns + bau_cells = {} for calc in relevant_calculations: try: @@ -2097,115 +2242,117 @@ def get_bau_column(col): if missing_entries: print(f"missing_entries in the input table: {', '.join(set(missing_entries))}. Please update the configuration if necessary.") - # Formats for the instructions sheet - title_format = workbook.add_format({ - 'bold': True, 'font_size': 18, 'align': 'left', 'valign': 'top' - }) - subtitle_format = workbook.add_format({ - 'bold': True, 'font_size': 14, 'align': 'left', 'valign': 'top' - }) - subsubtitle_format = workbook.add_format({ - 'italic': True, 'font_size': 12, 'align': 'left', 'valign': 'top', 'text_wrap': True - }) - text_format = workbook.add_format({ - 'font_size': 12, 'align': 'left', 'valign': 'top', 'text_wrap': True - }) - bullet_format = workbook.add_format({ - 'font_size': 12, 'align': 'left', 'valign': 'top', 'text_wrap': True, 'indent': 1 - }) + # Add Instructions worksheet content only for non-rates tables + if not is_rates_table: + # Formats for the instructions sheet + title_format = workbook.add_format({ + 'bold': True, 'font_size': 18, 'align': 'left', 'valign': 'top', 'text_wrap': True + }) + subtitle_format = workbook.add_format({ + 'bold': True, 'font_size': 14, 'align': 'left', 'valign': 'top', 'text_wrap': True + }) + subsubtitle_format = workbook.add_format({ + 'italic': True, 'font_size': 12, 'align': 'left', 'valign': 'top', 'text_wrap': True + }) + text_format = workbook.add_format({ + 'font_size': 12, 'align': 'left', 'valign': 'top', 'text_wrap': True + }) + bullet_format = workbook.add_format({ + 'font_size': 12, 'align': 'left', 'valign': 'top', 'text_wrap': True, 'indent': 1 + }) + + # Set column width and default row height + instructions_worksheet.set_column(0, 0, 100) + instructions_worksheet.set_default_row(15) + + # Start writing instructions + row = 0 + instructions_worksheet.write(row, 0, "Instructions for Using the REopt Results Table Workbook", title_format) + row += 2 + + # General Introduction + general_instructions = ( + "Welcome to the REopt Results Table Workbook !\n\n" + "This workbook contains all of the results of your selected REopt analysis scenarios. " + "Please read the following instructions carefully to understand how to use this workbook effectively." + ) + instructions_worksheet.write(row, 0, general_instructions, text_format) + row += 3 - # Set column width and default row height - instructions_worksheet.set_column(0, 0, 100) - instructions_worksheet.set_default_row(15) + # Using the 'Results Table' Sheet with formula format + instructions_worksheet.write(row, 0, "Using the 'Results Table' Sheet", subtitle_format) + row += 1 - # Start writing instructions - row = 0 - instructions_worksheet.write(row, 0, "Instructions for Using the REopt Results Table Workbook", title_format) - row += 2 + custom_table_instructions = ( + "The 'Results Table' sheet displays the scenario results of your REopt analysis in a structured format. " + "Here's how to use it:" + ) + instructions_worksheet.write(row, 0, custom_table_instructions, subsubtitle_format) + row += 2 + + steps = [ + "1. Review the Results: Browse through the table to understand the system capacities, financial metrics, and energy production details.", + "2. Identify Editable Fields: Look for yellow cells in the 'Playground' section where you can input additional incentives or costs.", + "3. Avoid Editing Formulas: Do not edit cells with blue background and white text, as they contain important formulas.", + "4. Interpreting BAU and Optimal Scenarios: 'BAU' stands for 'Business as Usual' and represents the baseline scenario without any new investments. 'Optimal' scenarios show the results with optimized investments.", + "5. Hidden BAU Columns: If all scenarios are for a single site, identical BAU columns may be hidden except for the first one. For multiple sites where financials and energy consumption differ, all BAU columns will be visible." + ] + for step in steps: + instructions_worksheet.write(row, 0, step, bullet_format) + row += 1 + row += 2 - # General Introduction - general_instructions = ( - "Welcome to the REopt Results Table Workbook !\n\n" - "This workbook contains all of the results of your selected REopt analysis scenarios. " - "Please read the following instructions carefully to understand how to use this workbook effectively." - ) - instructions_worksheet.write(row, 0, general_instructions, text_format) - row += 3 + # Notes for the Playground Section + instructions_worksheet.write(row, 0, "Notes for the economic 'Playground' Section", subtitle_format) + row += 1 - # Using the 'Results Table' Sheet with formula format - instructions_worksheet.write(row, 0, "Using the 'Results Table' Sheet", subtitle_format) - row += 1 + playground_notes = ( + "The economic 'Playground' section allows you to explore the effects of additional incentives and costs and on your project's financial metrics, in particular the simple payback period." + ) + instructions_worksheet.write(row, 0, playground_notes, subsubtitle_format) + row += 2 + + playground_items = [ + "- Total Capital Cost Before Incentives ($): For reference, to view what the payback would be without incentives.", + "- Total Capital Cost After Incentives Without MACRS ($): Represents the capital cost after incentives, but excludes MACRS depreciation benefits.", + "- Total Capital Cost After Non-Discounted Incentives ($): Same as above, but includes non-discounted MACRS depreciation, which provides tax benefits over the first 5-7 years.", + "- Additional Upfront Incentive ($): Input any additional grants or incentives (e.g., state or local grants).", + "- Additional Upfront Cost ($): Input any extra upfront costs (e.g., interconnection upgrades, microgrid components).", + "- Additional Yearly Cost Savings ($/yr): Input any ongoing yearly savings (e.g., avoided cost of outages, improved productivity, product sales with ESG designation).", + "- Additional Yearly Cost ($/yr): Input any additional yearly costs (e.g., microgrid operation and maintenance).", + "- Modified Total Year One Savings, After Tax ($): Updated total yearly savings to include any user-input additional yearly savings and cost.", + "- Modified Total Capital Cost ($): Updated total cost to include any user-input additional incentive and cost.", + "- Modified Simple Payback Period Without Incentives (yrs): Uses Total Capital Cost Before Incentives ($) to calculate payback, for reference.", + "- Modified Simple Payback Period (yrs): Calculates a simple payback period with Modified Total Year One Savings, After Tax ($) and Modified Total Capital Cost ($)." + ] + for item in playground_items: + instructions_worksheet.write(row, 0, item, bullet_format) + row += 1 + row += 1 - custom_table_instructions = ( - "The 'Results Table' sheet displays the scenario results of your REopt analysis in a structured format. " - "Here's how to use it:" - ) - instructions_worksheet.write(row, 0, custom_table_instructions, subsubtitle_format) - row += 2 - - steps = [ - "1. Review the Results: Browse through the table to understand the system capacities, financial metrics, and energy production details.", - "2. Identify Editable Fields: Look for yellow cells in the 'Playground' section where you can input additional incentives or costs.", - "3. Avoid Editing Formulas: Do not edit cells with blue background and white text, as they contain important formulas.", - "4. Interpreting BAU and Optimal Scenarios: 'BAU' stands for 'Business as Usual' and represents the baseline scenario without any new investments. 'Optimal' scenarios show the results with optimized investments.", - "5. Hidden BAU Columns: If all scenarios are for a single site, identical BAU columns may be hidden except for the first one. For multiple sites where financials and energy consumption differ, all BAU columns will be visible." - ] - for step in steps: - instructions_worksheet.write(row, 0, step, bullet_format) + # Unaddressable Heating Load and Emissions + instructions_worksheet.write(row, 0, "Notes for the emissions 'Playground' Section", subtitle_format) + row += 1 + + instructions_worksheet.write(row, 0, "The emissions 'Playground' section allows you to explore the effects of unaddressable fuel emissions on the total emissions reduction %.", subsubtitle_format) row += 1 - row += 2 - # Notes for the Playground Section - instructions_worksheet.write(row, 0, "Notes for the economic 'Playground' Section", subtitle_format) - row += 1 + unaddressable_notes = ( + "In scenarios where there is an unaddressable fuel load (e.g. heating demand that cannot be served by the technologies analyzed), " + "the associated fuel consumption and emissions are not accounted for in the standard REopt outputs.\n\n" + "The 'Unaddressable Fuel CO₂ Emissions' row in the 'Playground' section includes these emissions, providing a more comprehensive view of your site's total emissions. " + "Including unaddressable emissions results in a lower percentage reduction because the total emissions baseline is larger." + ) + instructions_worksheet.write(row, 0, unaddressable_notes, text_format) + row += 3 - playground_notes = ( - "The economic 'Playground' section allows you to explore the effects of additional incentives and costs and on your project's financial metrics, in particular the simple payback period." - ) - instructions_worksheet.write(row, 0, playground_notes, subsubtitle_format) - row += 2 - - playground_items = [ - "- Total Capital Cost Before Incentives ($): For reference, to view what the payback would be without incentives.", - "- Total Capital Cost After Incentives Without MACRS ($): Represents the capital cost after incentives, but excludes MACRS depreciation benefits.", - "- Total Capital Cost After Non-Discounted Incentives ($): Same as above, but includes non-discounted MACRS depreciation, which provides tax benefits over the first 5-7 years.", - "- Additional Upfront Incentive ($): Input any additional grants or incentives (e.g., state or local grants).", - "- Additional Upfront Cost ($): Input any extra upfront costs (e.g., interconnection upgrades, microgrid components).", - "- Additional Yearly Cost Savings ($/yr): Input any ongoing yearly savings (e.g., avoided cost of outages, improved productivity, product sales with ESG designation).", - "- Additional Yearly Cost ($/yr): Input any additional yearly costs (e.g., microgrid operation and maintenance).", - "- Modified Total Year One Savings, After Tax ($): Updated total yearly savings to include any user-input additional yearly savings and cost.", - "- Modified Total Capital Cost ($): Updated total cost to include any user-input additional incentive and cost.", - "- Modified Simple Payback Period Without Incentives (yrs): Uses Total Capital Cost Before Incentives ($) to calculate payback, for reference." - "- Modified Simple Payback Period (yrs): Calculates a simple payback period with Modified Total Year One Savings, After Tax ($) and Modified Total Capital Cost ($)." - ] - for item in playground_items: - instructions_worksheet.write(row, 0, item, bullet_format) + # Final Note and Contact Info + instructions_worksheet.write(row, 0, "Thank you for using the REopt Results Table Workbook!", subtitle_format) row += 1 - row += 1 - - # Unaddressable Heating Load and Emissions - instructions_worksheet.write(row, 0, "Notes for the emissions 'Playground' Section", subtitle_format) - row += 1 - - instructions_worksheet.write(row, 0, "The emissions 'Playground' section allows you to explore the effects of unaddressable fuel emissions on the total emissions reduction %.", subsubtitle_format) - row += 1 - - unaddressable_notes = ( - "In scenarios where there is an unaddressable fuel load (e.g. heating demand that cannot be served by the technologies analyzed), " - "the associated fuel consumption and emissions are not accounted for in the standard REopt outputs.\n\n" - "The 'Unaddressable Fuel CO₂ Emissions' row in the 'Playground' section includes these emissions, providing a more comprehensive view of your site's total emissions. " - "Including unaddressable emissions results in a lower percentage reduction because the total emissions baseline is larger." - ) - instructions_worksheet.write(row, 0, unaddressable_notes, text_format) - row += 3 - - # Final Note and Contact Info - instructions_worksheet.write(row, 0, "Thank you for using the REopt Results Table Workbook!", subtitle_format) - row += 1 - contact_info = "For support or feedback, please contact the REopt team at reopt@nrel.gov." - instructions_worksheet.write(row, 0, contact_info, subtitle_format) - # Freeze panes to keep the title visible - instructions_worksheet.freeze_panes(1, 0) + contact_info = "For support or feedback, please contact the REopt team at reopt@nrel.gov." + instructions_worksheet.write(row, 0, contact_info, subtitle_format) + # Freeze panes to keep the title visible + instructions_worksheet.freeze_panes(1, 0) # Close the workbook after all sheets are written workbook.close()