el1xr_opt.Modules.oM_Decomposition#

Benders decomposition and parallel block solving.

See docs/decomposition.md for the design and docs/rst/user-guide/decomposition.rst for the user-facing overview. The model is block-angular – investment is the first stage, the operating problem separates by (period, scenario) and (for long horizons) by time window, with storage coupling the time windows – so it can be solved by Benders instead of monolithically, reaching the same optimum.

Implemented and validated against the monolith:
  • benders_solve – the generic multi-cut L-shaped method (optimality cuts only, with an elastic penalty making every block feasible for any first-stage decision).

  • el1xr_benders – the el1xr investment/operating split, with optional process-parallel subproblem solves (BendersConfig.n_workers).

  • el1xr_temporal_benders – splits one operating horizon into time windows coupled by the storage inventory at each boundary, with the fixed network charge counted once in the master and the peak-demand charge handled as a threshold-LP linking variable.

  • partition_blocks / first_stage_components – the block partition and the complicating / linking variable names.

The only stub is solve_benders(model, ...), a deprecated alias kept for the original scaffold signature; use el1xr_benders(dir, case, date, ...) instead.

class el1xr_opt.Modules.oM_Decomposition.Block(period, scenario, load_levels, index)[source]#

Bases: object

One independent operating block of the problem.

A block is solved on its own given the first-stage (investment) decisions. load_levels is the slice of the time axis the block covers; for a pure period/scenario split it is the whole horizon, for a temporal split it is one time block.

Parameters:
  • period (object)

  • scenario (object)

  • load_levels (tuple)

  • index (int)

period: object#
scenario: object#
load_levels: tuple#
index: int#
el1xr_opt.Modules.oM_Decomposition.partition_blocks(periods, scenarios, load_levels, n_time_blocks=1)[source]#

Split the problem into independent operating blocks.

One block per (period, scenario) and, if n_time_blocks > 1, per contiguous chunk of the time axis. The blocks are independent given the investment decisions (and, across time blocks, given the storage level at the block boundaries), so they can be built and solved in parallel.

Returns a list of Block. This is the partition step that runs before model building.

Return type:

list

Parameters:

n_time_blocks (int)

el1xr_opt.Modules.oM_Decomposition.first_stage_components()[source]#

Names of the variables that couple the blocks. :rtype: dict

  • complicating — first-stage decisions shared by every block (the Benders master variables): the investment / sizing build fractions.

  • linking — variables shared only between consecutive time blocks when the time axis is split: the storage inventory at each block boundary.

Return type:

dict

class el1xr_opt.Modules.oM_Decomposition.BendersConfig(max_iterations=50, relative_gap=0.001, n_workers=1, n_time_blocks=1, extra=<factory>)[source]#

Bases: object

Settings for a (future) Benders solve.

Parameters:
  • max_iterations (int)

  • relative_gap (float)

  • n_workers (int)

  • n_time_blocks (int)

  • extra (dict)

max_iterations: int = 50#
relative_gap: float = 0.001#
n_workers: int = 1#
n_time_blocks: int = 1#
extra: dict#
el1xr_opt.Modules.oM_Decomposition.benders_solve(make_master, make_subproblem, blocks, config=None, solver='appsi_highs', solve_blocks=None)[source]#

Generic multi-cut (L-shaped) Benders decomposition.

Assumes relative complete recourse (every subproblem is feasible for any first-stage decision), so it adds optimality cuts only. The el1xr subproblems guarantee this with an elastic penalty relaxation (see el1xr_benders()), which turns any operating infeasibility into a high-cost recourse value whose fixing-constraint dual is a feasibility (steering) cut, so this loop needs no separate feasibility-cut handling. Returns a result dict with the objective, the first-stage solution, the iteration count and the final gap.

Subproblems are independent given the master decision, so they can be solved in parallel. Pyomo solvers are not thread-safe (shared writer / tempfile / solver state), so parallelism is by process, not thread: pass solve_blocks, a callable solve_blocks(x_hat) -> {block: (q_b, lam)} that solves the blocks however it likes (e.g. a pool of worker processes that each own and reuse their subproblems). When solve_blocks is given the subproblems are not built here (the pool owns them); when it is None the blocks are built and solved sequentially in-process. The result is identical either way.

Callbacks return plain Pyomo objects (so this works for the el1xr blocks or any two-stage stochastic program). make_master() returns a dict with keys model (master, minimise first-stage cost + sum theta), x (name -> first-stage Var), theta (block -> recourse Var) and cuts (a ConstraintList the solver appends optimality cuts to). make_subproblem(block) returns a dict with keys model (the operating subproblem), xcopy (name -> free copy of the first-stage Var), fix (name -> Constraint fixing the copy to a mutable-Param value, whose dual is the cut subgradient), set_xhat (callable setting the fixing-constraint values) and obj (the subproblem Objective). The subproblem must carry a dual Suffix.

el1xr_opt.Modules.oM_Decomposition.el1xr_temporal_benders(dir_name, case_name, date, n_time_blocks=2, solver='appsi_highs', config=None)[source]#

Solve one (period, scenario) el1xr operating horizon by temporal Benders.

The horizon is split into n_time_blocks contiguous time blocks coupled by the storage inventory at each boundary. The master holds the investment build fractions and the boundary inventory levels (linking variables, each shared by two adjacent blocks); each block is the operating model over its window with the investment and its incoming/outgoing boundary inventory fixed (the duals of the fixing constraints give the cuts), made always-feasible by the elastic penalty.

The per-scenario fixed network charge does not split by time window, so it is counted once in the master and removed from each block’s recourse. The peak demand charge (the per-month sum of the N largest grid imports) is the other horizon-coupling cost; it is reformulated as a threshold-LP whose scalar threshold per (month, retailer) is a master linking variable – the master holds N*t and the peak coefficient, each window adds its own sum_n (import_n - t)_+. With the grid import chosen by the optimiser (not pinned), the battery shaves the import peak across the window boundaries, so the storage-boundary coupling and the peak threshold are active at once; this reproduces the binary monolith exactly (tests/test_benders_temporal_el1xr.py::…endogenous_import…).

Transversality. The split couples the windows only through storage inventory and the registered aggregate costs, so it is transversal to the network representation (single-node / DC / linear three-phase – the network mode changes a window’s own constraints, not the coupling) and to the hydrogen sector (no horizon-coupling aggregate cost). The heat sector is handled too: its operating cost decomposes per window and is added to the recourse, and its thermal store is coupled by a boundary inventory (the St master variable, the analogue of Se / Sh for electricity / hydrogen storage). Still refused rather than mis-solved: any new sector aggregate charge that is not a plain sum over load levels and is not in the horizon-coupling registry (a constant or threshold charge must be registered, like the fixed network charge and the peak). This MVP also assumes hourly storage (cycle = 1), a single (period, scenario), and Hourly power tariffs (it raises on a Daily peak charge).

el1xr_opt.Modules.oM_Decomposition.el1xr_benders(dir_name, case_name, date, solver='appsi_highs', config=None)[source]#

Solve the el1xr investment + operating model by Benders decomposition.

Master: the investment build fractions plus a recourse variable per (period, scenario) block. Subproblem per block: the operating model restricted to that scenario, with the investment variables fixed (their fixing-constraint duals give the cut). Calls the validated benders_solve(). Returns its result dict. Validate against the monolithic optimum before trusting (see tests/test_benders_el1xr.py).

el1xr_opt.Modules.oM_Decomposition.solve_benders(model, optmodel, solver, config=None)[source]#

Deprecated alias kept for the original scaffold signature. Use el1xr_benders() (dir, case, date, …) for the el1xr model.

Parameters:

config (BendersConfig | None)