Skip to content

Commit 558afb7

Browse files
committed
feat: prototype first working version
1 parent 0ce48cb commit 558afb7

File tree

2 files changed

+115
-17
lines changed

2 files changed

+115
-17
lines changed

pypsa/optimization/optimize.py

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
define_transmission_volume_expansion_limit,
4949
)
5050
from pypsa.optimization.variables import (
51+
define_cvar_variables,
5152
define_loss_variables,
5253
define_modular_variables,
5354
define_nominal_variables,
@@ -109,7 +110,9 @@ def define_objective(n: Network, sns: pd.Index) -> None:
109110
"""
110111
weighted_cost: xr.DataArray | int
111112
m = n.model
112-
objective = []
113+
# Separate lists to distinguish CAPEX and OPEX terms
114+
capex_terms = []
115+
opex_terms = []
113116
is_quadratic = False
114117

115118
if n._multi_invest:
@@ -164,7 +167,8 @@ def define_objective(n: Network, sns: pd.Index) -> None:
164167
has_const = constant != 0
165168
if has_const:
166169
object_const = m.add_variables(constant, constant, name="objective_constant")
167-
objective.append(-1 * object_const)
170+
# Treat constant as part of CAPEX block
171+
capex_terms.append(-1 * object_const)
168172

169173
# Weightings
170174
weighting = n.snapshot_weightings.objective
@@ -193,7 +197,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
193197
cost = cost * weight
194198

195199
operation = m[var_name].sel(snapshot=sns, name=cost.coords["name"].values)
196-
objective.append((operation * cost).sum(dim=["name", "snapshot"]))
200+
opex_terms.append((operation * cost).sum(dim=["name", "snapshot"]))
197201

198202
# marginal cost quadratic
199203
for c_name, attr in lookup.query("marginal_cost_quadratic").index:
@@ -211,7 +215,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
211215
operation = m[f"{c.name}-{attr}"].sel(
212216
snapshot=sns, name=cost.coords["name"].values
213217
)
214-
objective.append((operation * operation * cost).sum(dim=["name", "snapshot"]))
218+
opex_terms.append((operation * operation * cost).sum(dim=["name", "snapshot"]))
215219
is_quadratic = True
216220

217221
# stand-by cost
@@ -231,7 +235,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
231235
status = m[f"{c.name}-status"].sel(
232236
snapshot=sns, name=stand_by_cost.coords["name"].values
233237
)
234-
objective.append((status * stand_by_cost).sum(dim=["name", "snapshot"]))
238+
opex_terms.append((status * stand_by_cost).sum(dim=["name", "snapshot"]))
235239

236240
# investment
237241
for c_name, attr in nominal_attrs.items():
@@ -258,7 +262,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
258262
weighted_cost = capital_cost * active
259263

260264
caps = m[f"{c.name}-{attr}"].sel(name=ext_i)
261-
objective.append((caps * weighted_cost).sum(dim=["name"]))
265+
capex_terms.append((caps * weighted_cost).sum(dim=["name"]))
262266

263267
# unit commitment
264268
keys = ["start_up", "shut_down"] # noqa: F841
@@ -275,27 +279,80 @@ def define_objective(n: Network, sns: pd.Index) -> None:
275279
continue
276280

277281
var = m[f"{c.name}-{attr}"].sel(name=com_i)
278-
objective.append((var * cost).sum(dim=["name", "snapshot"]))
282+
opex_terms.append((var * cost).sum(dim=["name", "snapshot"]))
279283

280-
if not objective:
284+
if not (capex_terms or opex_terms):
281285
msg = (
282286
"Objective function could not be created. "
283287
"Please make sure the components have assigned costs."
284288
)
285289
raise ValueError(msg)
286290

287-
terms = []
288-
if n.has_scenarios:
289-
# Apply scenario probabilities as weights to the objective
291+
# Build expected CAPEX and expected OPEX (scenario-weighted if stochastic)
292+
def _expected(exprs: list) -> Any:
293+
if not exprs:
294+
return 0
295+
if n.has_scenarios:
296+
terms = []
297+
for s, p in n.scenario_weightings["weight"].items():
298+
selected = [e.sel(scenario=s) for e in exprs]
299+
merged = merge(selected)
300+
terms.append(merged * p)
301+
return sum(terms) if is_quadratic else merge(terms)
302+
return sum(exprs) if is_quadratic else merge(exprs)
303+
304+
expected_capex = _expected(capex_terms)
305+
expected_opex = _expected(opex_terms)
306+
307+
# CVaR augmentation if enabled
308+
if getattr(n, "has_scenarios", False) and getattr(n, "has_risk_preference", False):
309+
alpha = n.risk_preference.get("alpha") # type: ignore[assignment]
310+
omega = n.risk_preference.get("omega") # type: ignore[assignment]
311+
312+
# Validate risk preference parameters
313+
if not (isinstance(alpha, int | float) and 0 < alpha < 1):
314+
_msg_alpha = f"alpha must be a number between 0 and 1, got {alpha}"
315+
raise ValueError(_msg_alpha)
316+
if not (isinstance(omega, int | float) and 0 <= omega <= 1):
317+
_msg_omega = f"omega must be a number between 0 and 1, got {omega}"
318+
raise ValueError(_msg_omega)
319+
320+
# Create per-scenario OPEX expressions to use in constraints
321+
scen_opex_exprs = {}
322+
for s in n.scenarios:
323+
scen_selected = [e.sel(scenario=s) for e in opex_terms]
324+
scen_opex_exprs[s] = (
325+
sum(scen_selected) if is_quadratic else merge(scen_selected)
326+
)
327+
328+
# Retrieve CVaR auxiliary variables
329+
a = m["CVaR-a"]
330+
theta = m["CVaR-theta"]
331+
cvar = m["CVaR"]
332+
333+
# a(s) >= OPEX(s) - theta -> a(s) - OPEX(s) + theta >= 0
334+
for s in n.scenarios:
335+
lhs = a.sel(scenario=s) - scen_opex_exprs[s] + theta
336+
m.add_constraints(lhs, ">=", 0, name=f"CVaR-excess-{s}")
337+
338+
# theta + 1/(1-alpha)*sum_s p_s * a(s) <= cvar
339+
inv_tail = 1.0 / (1.0 - float(alpha))
340+
weighted_a = None
290341
for s, p in n.scenario_weightings["weight"].items():
291-
selected = [e.sel(scenario=s) for e in objective]
292-
merged = merge(selected)
293-
terms.append(merged * p)
342+
term = a.sel(scenario=s) * float(p)
343+
weighted_a = term if weighted_a is None else weighted_a + term
344+
m.add_constraints(theta + inv_tail * weighted_a, "<=", cvar, name="CVaR-def")
345+
# Final objective per spec:
346+
# CAPEX + (1-omega) * E[OPEX] + omega * CVaR
347+
obj_expr = (
348+
expected_capex + (1 - float(omega)) * expected_opex + float(omega) * cvar
349+
)
294350
else:
295-
terms = objective
351+
# Deterministic or no risk: CAPEX + OPEX
352+
obj_expr = expected_capex + expected_opex
296353

297-
# Ensure we're returning the correct expression type (MGA compatibility)
298-
m.objective = sum(terms) if is_quadratic else merge(terms)
354+
# Set objective
355+
m.objective = obj_expr
299356

300357

301358
class OptimizationAccessor(OptimizationAbstractMixin):
@@ -469,6 +526,9 @@ def create_model(
469526
define_spillage_variables(n, sns)
470527
define_operational_variables(n, sns, "Store", "p")
471528

529+
# CVaR auxiliary variables (only when stochastic + risk preference is set)
530+
define_cvar_variables(n)
531+
472532
if transmission_losses:
473533
for c in n.passive_branch_components:
474534
define_loss_variables(n, sns, c)
@@ -616,6 +676,14 @@ def assign_solution(self) -> None:
616676
if name == "objective_constant":
617677
continue
618678

679+
# Skip auxiliary CVaR variables
680+
if name.startswith("CVaR"):
681+
continue
682+
683+
# Skip variables without component-attribute naming
684+
if "-" not in name:
685+
continue
686+
619687
_c_name, attr = name.split("-", 1)
620688
c = n.c[_c_name]
621689
df = _from_xarray(sol, c)

pypsa/optimization/variables.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,33 @@ def define_loss_variables(n: Network, sns: Sequence, c_name: str) -> None:
234234
active = c.da.active.sel(name=c.active_assets, snapshot=sns)
235235
coords = active.coords
236236
n.model.add_variables(0, coords=coords, name=f"{c.name}-loss", mask=active)
237+
238+
239+
def define_cvar_variables(n: Network) -> None:
240+
"""Define auxiliary variables for CVaR risk formulation.
241+
242+
Adds the following variables when stochastic optimization with CVaR is enabled:
243+
- CVaR-a(scenario): auxiliary variable per scenario
244+
- CVaR-theta: VaR level (scalar)
245+
- CVaR: Conditional Value at Risk (scalar)
246+
247+
Requirements
248+
------------
249+
- n.has_scenarios must be True
250+
- n.has_risk_preference must be True
251+
"""
252+
if not getattr(n, "has_scenarios", False):
253+
return
254+
if not getattr(n, "has_risk_preference", False):
255+
return
256+
257+
# Per-scenario auxiliary variables a[s]
258+
scenarios = n.scenarios
259+
if scenarios is None or len(scenarios) == 0:
260+
return
261+
262+
# Non-negative excess loss variables per scenario
263+
n.model.add_variables(lower=0, coords=[scenarios], name="CVaR-a")
264+
# Scalar theta (VaR) and CVaR
265+
n.model.add_variables(name="CVaR-theta")
266+
n.model.add_variables(name="CVaR")

0 commit comments

Comments
 (0)