Skip to content

Commit 94b9a51

Browse files
committed
Tidy epsilon objectives
1 parent 7430ad0 commit 94b9a51

2 files changed

Lines changed: 76 additions & 40 deletions

File tree

src/muse/decisions.py

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def weighted_sum(objectives: Dataset, parameters: Any, **kwargs) -> DataArray:
4949
from xarray import DataArray, Dataset
5050

5151
from muse.registration import registrator
52-
from muse.timeslices import drop_timeslice
52+
from muse.timeslices import broadcast_timeslice, drop_timeslice
5353
from muse.utilities import tupled_dimension
5454

5555
PARAMS_TYPE = Sequence[tuple[str, bool, float]]
@@ -351,16 +351,33 @@ def _lexical_comparison(
351351

352352

353353
def _epsilon_constraints(
354-
objectives: Dataset, optimize: str, mask: Any | None = None, **epsilons
354+
objectives: Dataset,
355+
optimize: str,
356+
mask: Any | None = None,
357+
**epsilons,
355358
) -> DataArray:
356-
"""Minimizes one objective subject to constraints on other objectives."""
359+
"""Selects the best value of a target objective subject to epsilon constraints.
360+
361+
Each constraint enforces that an objective must be below (or above, after
362+
sign handling upstream) a threshold, aggregated over all non-(asset,
363+
replacement) dimensions.
364+
"""
365+
# Start with all options feasible
357366
constraints = True
367+
368+
# Build feasibility mask from epsilon constraints
358369
for name, epsilon in epsilons.items():
370+
# Reduce over all non-decision dimensions (e.g. timeslice, region)
359371
reduced_dims = set(objectives[name].dims) - {"asset", "replacement"}
372+
373+
# All slices must satisfy constraint
360374
constraints = constraints & (objectives[name] <= epsilon).all(reduced_dims)
361375

376+
# Default mask = something worse than any feasible objective value
362377
if mask is None:
363378
mask = objectives[optimize].max() + 1
379+
380+
# Return objective values, masking infeasible alternatives
364381
return objectives[optimize].where(constraints, mask)
365382

366383

@@ -370,60 +387,79 @@ def epsilon_constraints(
370387
parameters: PARAMS_TYPE | Sequence[tuple[str, bool, float]],
371388
mask: Any | None = None,
372389
) -> DataArray:
373-
r"""Minimizes first objective subject to constraints on other objectives.
390+
"""Epsilon-constraint optimisation.
374391
375-
The parameters are a sequence of tuples `(name, minimize, epsilon)`, where
376-
`name` is the name of the objective, `minimize` is `True` if minimizing and
377-
false if maximizing that objective, and `epsilon` is the constraint. The
378-
first objective is the one that will be minimized according to:
392+
The first objective is optimised (min or max), while all subsequent
393+
objectives are treated as constraints of the form:
379394
380-
Given objectives :math:`O^{(i)}_t`, with :math:`i \in [|1, N|]` and :math:`t` the
381-
replacement technologies, this function computes the ranking with respect to
382-
:math:`t`:
395+
objective_i <= epsilon_i
383396
384-
.. math::
397+
after sign normalization.
398+
"""
399+
assert set(objectives.data_vars).issuperset([p[0] for p in parameters])
385400

386-
\mathrm{ranking}_{O^{(i)}_t < \epsilon_i} O^{(0)}_t
401+
# Remove obj_data parameters if present
402+
optimize_name, optimize_minimize, _ = parameters[0]
387403

404+
# Encode optimization direction
405+
do_minimize = Dataset({optimize_name: 1 if optimize_minimize else -1})
388406

389-
The first tuple can be restricted to `(name, minimize)`, since `epsilon` is ignored.
407+
# Remaining objectives also get sign encoding
408+
for name, minimize, _ in parameters[1:]:
409+
do_minimize[name] = coeff_sign(minimize, 1)
390410

391-
The result is the matrix :math:`O^{(0)}` modified such minimizing over the
392-
replacement dimension value would take into account the constraints and the
393-
optimization direction (minimize or maximize). In other words, calling
394-
`result.rank('replacement')` will yield the expected result.
395-
"""
396-
assert set(objectives.data_vars).issuperset([param[0] for param in parameters])
397-
do_minimize = Dataset({k: coeff_sign(v, 1) for k, v, _ in parameters[1:]})
398-
do_minimize[parameters[0][0]] = 1 if parameters[0][1] else -1
399-
dict_params = {k: v for k, _, v in parameters[1:] if k in objectives.data_vars}
400-
constraints = do_minimize * Dataset(dict_params)
411+
# Extract epsilon constraints
412+
epsilons = {
413+
name: coeff_sign(minimize, 1) * eps
414+
for name, minimize, eps in parameters[1:]
415+
if name in objectives.data_vars
416+
}
417+
418+
# Apply sign transformation + constraints
419+
if "timeslice" in objectives.indexes:
420+
do_minimize = broadcast_timeslice(do_minimize)
401421
return _epsilon_constraints(
402-
objectives * do_minimize, parameters[0][0], mask=mask, **constraints.data_vars
422+
objectives * do_minimize,
423+
optimize_name,
424+
mask=mask,
425+
**epsilons,
403426
)
404427

405428

406429
@register_decision(name="retro_epsilon")
407430
def retro_epsilon_constraints(
408-
objectives: Dataset, parameters: PARAMS_TYPE
431+
objectives: Dataset,
432+
parameters: PARAMS_TYPE,
409433
) -> DataArray:
410-
"""Epsilon constraints where the current tech is included.
434+
"""Epsilon-constraint optimisation with asset-relative thresholds.
411435
412-
Modifies the parameters to the function such that the existing technologies are
413-
always competitive.
436+
Epsilon thresholds are adjusted so that the current technology is always
437+
feasible, ensuring it remains in the choice set.
414438
"""
439+
# Extract current asset baseline
415440
asset_objectives = objectives.sel(replacement=objectives.asset)
416441

417-
def transform(name, minimize, epsilon=None):
442+
def adapt_param(name, minimize, epsilon=None):
443+
"""Adjust epsilon so that current asset is always feasible."""
418444
if epsilon is None:
419445
return name, minimize
420-
am = getattr(asset_objectives, name)
421-
new_eps = am.where(am > epsilon if minimize else am < epsilon, epsilon)
422-
return name, minimize, new_eps
423446

424-
parameters = [
425-
transform(*param) for param in parameters if param[0] in objectives.data_vars
426-
]
447+
current = asset_objectives[name]
448+
449+
# Work in the same transformed logic as epsilon_constraints
450+
sign = -1 if minimize else 1
451+
452+
# Ensure current asset is not excluded by its own constraint
453+
adjusted = current.where(
454+
(sign * current) <= (sign * epsilon),
455+
epsilon,
456+
)
457+
458+
return name, minimize, adjusted
459+
460+
# Filter valid objectives and adapt epsilons
461+
parameters = [adapt_param(*p) for p in parameters if p[0] in objectives.data_vars]
462+
427463
return epsilon_constraints(objectives, parameters)
428464

429465

tests/test_decisions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,27 +129,27 @@ def reshape_array(size, shape, start=1):
129129

130130
# Test case 1: Basic constraints
131131
expected = objectives.a * (objectives.asset == objectives.asset)
132-
params = [("a", True), ("b", True, objectives.b.max() + 1)]
132+
params = [("a", True, 1), ("b", True, objectives.b.max() + 1)]
133133
actual = epsilon_constraints(objectives, params)
134134
assert actual.values == approx(expected.values)
135135

136136
# Test case 2: Negative constraints
137137
expected = -objectives.a * (objectives.asset == objectives.asset)
138-
params = [("a", False), ("b", True, objectives.b.max() + 1)]
138+
params = [("a", False, 1), ("b", True, objectives.b.max() + 1)]
139139
actual = epsilon_constraints(objectives, params)
140140
assert actual.values == approx(expected.values)
141141

142142
# Test case 3: Binary choice constraints
143143
objectives.b[:] = choice((1, 2), objectives.b.size).reshape(objectives.b.shape)
144-
params = [("a", True), ("b", True, 1.5)]
144+
params = [("a", True, 1), ("b", True, 1.5)]
145145
expected = objectives.a.where(objectives.b == 1).fillna(-1)
146146
actual = epsilon_constraints(objectives, params, mask=-1)
147147
assert actual.values == approx(expected.values)
148148

149149
# Test case 4: Multiple constraints
150150
objectives.b[:] = choice((1, 2), objectives.b.size).reshape(objectives.b.shape)
151151
objectives.c[:] = choice((1, 2, 3), objectives.c.size).reshape(objectives.c.shape)
152-
params = [("a", True), ("b", True, 1.5), ("c", False, 1.2)]
152+
params = [("a", True, 1), ("b", True, 1.5), ("c", False, 1.2)]
153153
condition = (objectives.b == 1) & (objectives.c >= 2).all("other")
154154
expected = objectives.a.where(condition).fillna(-1)
155155
actual = epsilon_constraints(objectives, params, mask=-1)

0 commit comments

Comments
 (0)