Demand-charge epigraph: scope to the assessed window, bound _max, expose a handle
@dalyw is already working on this.
Since Aug 25, 2026.
- 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. Whenlimit == 0— the untiered
case, and the common one —_limit[t]is just consumption, which is
non-negative, so_multiply[t] >= 0andlb=0on_maxis valid.- When
limit > 0,_limit[t]is negative wherever consumption sits below the
limit, so_multiply[t]can be negative and_maxlegitimately goes
negative.max_posclamps 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.