4848 define_transmission_volume_expansion_limit ,
4949)
5050from 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
301358class 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 )
0 commit comments