we3lab / we3lab/eeco

Demand-charge epigraph: scope to the assessed window, bound _max, expose a handle

Open
#60 0 comments 0 reactions 1 assignee View on GitHub

@dalyw is already working on this.

Since Aug 25, 2026.

enhancement
Dominant language
Python
Stars
11
Forks
4
Avg merge
5d 4h
Merged PRs (30d)
1

Description

Three small tightenings to the Pyomo path in calculate_demand_cost. All are about the size and relaxation quality of the model we hand the solver

Low priority. Flagging because they show up sharply in downstream MIPs: the reproducer is a unit-commitment scheduling model where the demand charge's linear relaxation is what drives the branch-and-bound, and a 2976-step month against our formulation carries several thousand rows that presolve exists only to delete.

1. The epigraph is built over the whole horizon, not the charge's window

ut.multiply(var, charge_array, ...) builds _multiply[t] over
model._var_index, and ut.max then builds _multiply[t] <= _max over the
same full index. Outside the assessed window charge_array[t] is 0.0, so
those rows degenerate to 0 <= _max. The _limit[t] == consumption_data[t] - limit identity rows are built over the full index too.

On a 2976-step month with a 16:00–21:00 window that is 2356 of 2976 epigraph
rows structurally vacuous
, and the same again in _limit and _multiply.
Per monthly demand charge we currently emit 2N+2 variables and 3N+1
constraints where |window| would do.

charge_array is a plain numpy array known at build time, so the active steps
are just np.nonzero(charge_array)[0]. The change is to build the three
components against that index rather than model._var_index — either by
filtering in calculate_demand_cost before the ut.multiply call, or by
having ut.multiply skip exact-zero coefficients. The latter is more invasive
but would also help calculate_energy_cost, which has the same pattern per TOU
period (19 of 24 rows zero for a 5-hour peak period).

2. _max is free and uninitialized — but only conditionally boundable

ut.max creates _max with lb=None, ub=None, value=None.

I initially wrote this up as "lb=0 is always sound" and that is wrong, so
worth stating carefully:

  • _limit[t] == consumption_data[t] - limit. When limit == 0 — the untiered
    case, and the common one — _limit[t] is just consumption, which is
    non-negative, so _multiply[t] >= 0 and lb=0 on _max is valid.
  • When limit > 0, _limit[t] is negative wherever consumption sits below the
    limit, so _multiply[t] can be negative and _max legitimately goes
    negative. max_pos clamps it afterwards, which is exactly why it can. lb=0
    would be unsound here.

So the bound should be conditioned on limit == 0 rather than applied
unconditionally. An initialize= would also help warm-started re-solves;
_max and _multiply[t] are currently the only components in the chain with
value=None, which makes them awkward for anything reading a solution back.

3. No structured handle on the components we create

The only way for a caller to reach the Var holding the billed peak is to match
on our generated names:

peak, = [v for v in block.component_objects(pyo.Var)
         if "demand" in v.name and v.name.endswith("_max")]

That is what a downstream package has to write today, and it breaks the moment
we change varstr construction. calculate_demand_cost already knows
everything needed; returning or recording a mapping of charge name →
{peak_var, window_indices, rate} alongside the cost expression would make the
peak addressable without depending on naming.

The motivating use case: adding a valid inequality against the billed peak is a
standard way to strengthen a demand-charge MIP, and it is currently
impractical to do from outside EECO.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.