Skip to content

Allow BESS to Export#471

Draft
adfarth wants to merge 60 commits into
developfrom
bess-export
Draft

Allow BESS to Export#471
adfarth wants to merge 60 commits into
developfrom
bess-export

Conversation

@adfarth
Copy link
Copy Markdown
Collaborator

@adfarth adfarth commented Jan 7, 2025

Added

  • Add decision variable dvStorageToGrid and ElectricStorage input options for grid export: can_net_meter, can_wholesale, can_export_beyond_nem_limit (all default to False)

TODO:

  • Add BESS inputs for can_net_meter, can_wholesale, can_export_beyond_nem_limit
  • index dvStorageToGrid on metering type, following the logic of tech export bins
  • Determine whether to use dvs for total BESS discharge & BESStoGrid vs. BESStoLoad & BESStoGrid --> Choosing to mirror tech production by using dvDischargeFromStorage as total discharge with dvStorageToGrid
  • Incorporate dvStorageToGrid into load balancing constraints
  • Test outputs against expected values
  • Review and update emissions calculations to account for BESS export
  • Address TODOs throughout code
  • Check and remove all references to "can_export_to_grid" (currently commented out)

Things to test:

  • BESS has NEM and PV does not --> currently errors. should allow this (and test)
  • BESS has wholesale and PV does not --> should allow this (and test)
  • All 3 new inputs for BESS
  • Different solvers (with vs without indicator constraint compatibility)
  • PV has NEM or WHL and BESS does not
  • Make sure BESS serves load first
  • Test with thermal storage

Known issues:

  • If you have a net_metering_limit_kw > 0, PV.can_net_meter = false, and ElectricStorage.can_net_meter = true, will error --> Could maybe add a check that not only BESS can net meter without a generating tech? Or just allow this?
  • ElectricStorage can end up being sized, and export, much more than the peak load (this is currently true in develop, too, but now the issue is more extreme)

Nice to haves / future work:

  • Test solve time impacts compared to master branch (impacting solve times even without allowing BESS to export?)
  • Check export in MPC -- not currently high priority so could remove if this adds a lot of work
  • Consider adding other frameworks outside of net metering or net billing. E.g. BESS can export, potentially not receive any NEM or NB compensation, and receive a per kWh compensation during defined periods (similar, but different from, CPs). Or: BESS can export but doesn't receive any compensation at all.
  • If compensated through net metering, assess whether the BESS capacity should be included in the net_metering_limit_kw. --> Xiang and I think BESS should not contribute towards NEM limit

WHL_benefit = 0
NEM_techs = String[t for t in p.techs.elec if :NEM in p.export_bins_by_tech[t]]
WHL_techs = String[t for t in p.techs.elec if :WHL in p.export_bins_by_tech[t]]
NEM_storage = String[b for b in p.s.storage.types.elec if :NEM in p.export_bins_by_storage[b]]
Copy link
Copy Markdown
Collaborator

@hdunham hdunham Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really about what you added, but isn't NEM_techs just equal to techs_by_exportbins[:NEM]? Same with WHL_techs, NEM_storage, WHL_storage. Do you agree? If so idk why we are recreating it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I just double checked and I agree. We could probably replace all instances of NEM_techs and WHL_techs with their techs_by_export bin equivalent

Copy link
Copy Markdown
Collaborator Author

@adfarth adfarth Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hdunham it looks like you made this change and then reverted it. I'm assuming it broke some things and if so can I resolve this convo?

Comment thread src/constraints/storage_constraints.jl Outdated
Comment thread src/constraints/storage_constraints.jl Outdated
@constraint(m, [b in p.s.storage.attr[b], ts in p.time_steps_with_grid],
sum(m[Symbol("dvStorageToGrid"*_n)][b, u, ts] for u in p.export_bins_by_storage[b])
<=
m[Symbol("dvDischargeFromStorage"*_n)][b, ts] # TODO: should this be divided by p.s.storage.attr[b].discharge_efficiency ?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no discharge_efficiency here, that is applied in the SOC constraints (4g)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be sure I understand, is dvProductionToStorage on the AC side of the converter (so, before efficiency losses) and dvDischargeFromStorage is on the AC side of the inverter (so, after efficiency losses)? So everything is AC

Comment thread src/core/energy_storage/electric_storage.jl Outdated
Comment thread src/core/reopt.jl Outdated
Comment thread src/core/reopt.jl Outdated
Comment thread src/core/reopt_multinode.jl Outdated
Comment thread src/mpc/model_multinode.jl Outdated
Comment thread src/core/reopt_inputs.jl Outdated
Comment thread src/core/reopt_inputs.jl Outdated
Comment thread src/constraints/electric_utility_constraints.jl
Comment thread src/mpc/inputs.jl
for t in techs.elec
export_bins_by_tech[t] = s.electric_tariff.export_bins
end
# TODO implement export bins by tech (rather than assuming that all techs share the export_bins)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hdunham how does this suggestion differ from export_bins_by_tech?

@adfarth adfarth changed the title Allow BESS to Export Allow BESS to Export and Fixed SOC Jul 9, 2025
Copy link
Copy Markdown
Collaborator

@zolanaj zolanaj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval of big-M implementation

Comment thread src/constraints/electric_utility_constraints.jl
@adfarth adfarth requested review from Copilot May 5, 2026 16:55
this is all now in cost-to-utility branch
@adfarth adfarth changed the title Allow BESS to Export and Fixed SOC Allow BESS to Export May 5, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/mpc/structs.jl
Comment thread src/core/energy_storage/electric_storage.jl
Comment thread test/development_tests.jl
Comment on lines +19 to +23
m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false))
results = run_reopt(m, p)
open("debug_results.json","w") do f
JSON.print(f, results, 4)
end
Comment thread test/development_tests.jl
Comment on lines +42 to +47
d["ElectricUtility"]["allow_simultaneous_export_import"] = false
m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false))
results = run_reopt(m, d)
open("debug_results2.json","w") do f
JSON.print(f, results, 4)
end
Comment thread test/development_tests.jl
# d["ElectricStorage"]["can_wholesale"] = true
d["ElectricStorage"]["can_export_beyond_nem_limit"] = true
p = REoptInputs(d)
println(p.export_bins_by_storage["ElectricStorage"])
Comment thread test/runtests.jl
Comment on lines 346 to 358
@testset "January Export Rates" begin
model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false))
data = JSON.parsefile("./scenarios/monthly_rate.json")

# create wholesale_rate with compensation in January > retail rate
# 1) create wholesale_rate with compensation in January > retail rate
# and check that PV exports instead of serving load
jan_rate = data["ElectricTariff"]["monthly_energy_rates"][1]
data["ElectricTariff"]["wholesale_rate"] =
append!(repeat([jan_rate + 0.1], 31 * 24), repeat([0.0], 8760 - 31*24))
data["ElectricTariff"]["monthly_demand_rates"] = repeat([0], 12)

model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false))
s = Scenario(data)
Comment on lines 61 to 83
m[:AnnualOnsiteREEleckWh] = @expression(m, p.hours_per_time_step * (
#total RE elec generation, excl steam turbine
sum(p.production_factor[t,ts] * p.levelization_factor[t] * m[:dvRatedProduction][t,ts] *
p.tech_renewable_energy_fraction[t] for t in setdiff(p.techs.elec, p.techs.steam_turbine), ts in p.time_steps
) - #total RE elec generation, excl steam turbine
sum(m[:dvProductionToStorage][b,t,ts]*p.tech_renewable_energy_fraction[t]*(
)
#minus battery efficiency losses
- sum(m[:dvProductionToStorage][b,t,ts]*p.tech_renewable_energy_fraction[t]*(
1-p.s.storage.attr[b].charge_efficiency*p.s.storage.attr[b].discharge_efficiency)
for t in setdiff(p.techs.elec, p.techs.steam_turbine), b in p.s.storage.types.elec, ts in p.time_steps
) - #minus battery efficiency losses
sum(m[:dvCurtail][t,ts] * p.tech_renewable_energy_fraction[t]
)
# minus curtailment
- sum(m[:dvCurtail][t,ts] * p.tech_renewable_energy_fraction[t]
for t in setdiff(p.techs.elec, p.techs.steam_turbine), ts in p.time_steps
) - # minus curtailment
(1 - p.s.site.include_exported_renewable_electricity_in_total) *
)
# minus exported RE, if RE accounting method = 0.
- (1 - p.s.site.include_exported_renewable_electricity_in_total) *
sum(m[:dvProductionToGrid][t,u,ts]*p.tech_renewable_energy_fraction[t]
for t in setdiff(p.techs.elec, p.techs.steam_turbine), u in p.export_bins_by_tech[t], ts in p.time_steps
) # minus exported RE, if RE accounting method = 0.
)
# TODO: battery can now discharge to the grid, but this export is not yet accounted for in the RE calculation.
# This is only relevant if include_exported_renewable_electricity_in_total = false. If false, the
# battery could become a "back door" for export of RE to the grid since it's not subtracted here. Will require tracking of RE in BESS.
)
Comment on lines +28 to 38
if !isempty(vcat(NEM_techs, NEM_storage))
# Constraint (9c): Net metering only -- can't sell more than you purchase
# hours_per_time_step is cancelled on both sides, but used for unit consistency (convert power to energy)
@constraint(m,
p.hours_per_time_step * sum( m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts]
for t in NEM_techs, ts in p.time_steps)
p.hours_per_time_step * (
sum( m[Symbol("dvProductionToGrid"*_n)][t, :NEM, ts] for t in NEM_techs, ts in p.time_steps) +
sum( m[Symbol("dvStorageToGrid"*_n)][b, :NEM, ts] for b in NEM_storage, ts in p.time_steps)
)
<= p.hours_per_time_step * sum( m[Symbol("dvGridPurchase"*_n)][ts, tier]
for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers)
)
Comment thread src/core/reopt.jl
Comment on lines 661 to 663
binGHP[p.ghp_options], Bin # Can be <= 1 if require_ghp_purchase=0, and is ==1 if require_ghp_purchase=1
dvNetLoad[p.time_steps] # Net load after on-site generation and storage [kW]
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants