Repository: PyPSA/PyPSA

Issue:
`n.statistics.opex()` omit start-up, shut-down and quadratic marginal costs
### Version Checks (indicate both or one)

- [x] I have confirmed this bug exists on the lastest [release](https://github.com/pypsa/pypsa/releases) of PyPSA.

- [x] I have confirmed this bug exists on the current [`master`](https://github.com/pypsa/pypsa/tree/master) branch of PyPSA.


### Issue Description

`n.statistics.opex()` does not include start-up, shut-down, and quadratic marginal costs. However, they should be as they would be considered operational expenditures.



### Reproducible Example

```python
--
```

### Expected Behavior

Ultimately, we want total system costs to be calculated by:

```py
n.statistics.opex().sum() + n.statistics.capex().sum()
```

### Installed Versions

<details>
Replace this line.
</details>



Discussion:

Patch:
diff --git a/doc/references/release-notes.rst b/doc/references/release-notes.rst
index 233682336..b0ebc676d 100644
--- a/doc/references/release-notes.rst
+++ b/doc/references/release-notes.rst
@@ -5,7 +5,7 @@ Release Notes
 Upcoming Release
 ================
 
-.. .. warning:: 
+.. warning:: 
   
    The features listed below are not released yet, but will be part of the next release! 
    To use the features already you have to install the ``master`` branch, e.g. 
@@ -23,11 +23,19 @@ Upcoming Release
   The new modularized maps module allows for more flexibility and easier
   maintenance. 
 
+
+* The function ``n.statistics.opex()`` now includes all operational cost
+  components: marginal costs, quadratic marginal costs, storage costs, spill
+  costs, start-up costs, shut-down costs, and stand-by costs. Previously, only
+  marginal costs were considered. A new parameter `cost_types` allows selecting
+  which cost components to include. (https://github.com/PyPSA/PyPSA/pull/1195)
+
 * New method `n.equals() <pypsa.Network.equals>` to compare two networks for equality. 
   This is similar to the equality operator `==` but allows for more flexibility in the
   comparison which is useful for testing and debugging.
   (https://github.com/PyPSA/PyPSA/pull/1194)
 
+
 `v0.34.0 <https://github.com/PyPSA/PyPSA/releases/tag/v0.34.0>`__ (25th March 2025)
 =======================================================================================
 
diff --git a/pypsa/data/component_attrs/generators.csv b/pypsa/data/component_attrs/generators.csv
index 6343a2552..401cf17c9 100644
--- a/pypsa/data/component_attrs/generators.csv
+++ b/pypsa/data/component_attrs/generators.csv
@@ -39,7 +39,9 @@ weight,float,n/a,1,"Weighting of a generator. Only used for network clustering."
 p,series,MW,0.,active power at bus (positive if net generation),Output
 q,series,MVar,0.,reactive power (positive if net generation),Output
 p_nom_opt,float,MW,0.,Optimised nominal power.,Output
-status,series,n/a,1,"Status (1 is on, 0 is off). Only outputted if committable is True.",Output
+status,series,n/a,1,"Status in the snapshot (1 is on, 0 is off). Only outputted if committable is True.",Output
+start_up,series,n/a,1.,"Whether the unit was started in the snapshot (1 is yes, 0 is no). Only outputted if committable is True.",Output
+shut_down,series,n/a,1.,"Whether the unit was shut down in the snapshot (1 is yes, 0 is no).  Only outputted if committable is True.",Output
 mu_upper,series,currency/MWh,0.,Shadow price of upper p_nom limit,Output
 mu_lower,series,currency/MWh,0.,Shadow price of lower p_nom limit,Output
 mu_p_set,series,currency/MWh,0.,Shadow price of fixed power generation p_set,Output
diff --git a/pypsa/data/component_attrs/links.csv b/pypsa/data/component_attrs/links.csv
index 41fb9032f..44ebf3997 100644
--- a/pypsa/data/component_attrs/links.csv
+++ b/pypsa/data/component_attrs/links.csv
@@ -36,7 +36,9 @@ ramp_limit_shut_down,float,per unit,1,"Maximum decrease at shut down, per unit o
 p0,series,MW,0.,Active power at bus0 (positive if branch is withdrawing power from bus0).,Output
 p1,series,MW,0.,Active power at bus1 (positive if branch is withdrawing power from bus1).,Output
 p_nom_opt,float,MW,0,Optimised capacity for active power.,Output
-status,series,n/a,1.,"Status (1 is on, 0 is off). Only outputted if committable is True.",Output
+status,series,n/a,1.,"Status in the snapshot (1 is on, 0 is off). Only outputted if committable is True.",Output
+start_up,series,n/a,1.,"Whether the unit was started in the snapshot (1 is yes, 0 is no). Only outputted if committable is True.",Output
+shut_down,series,n/a,1.,"Whether the unit was shut down in the snapshot (1 is yes, 0 is no).  Only outputted if committable is True.",Output
 mu_lower,series,currency/MW,0.,Shadow price of lower p_nom limit  -F \leq f. Always non-negative.,Output
 mu_upper,series,currency/MW,0.,Shadow price of upper p_nom limit f \leq F. Always non-negative.,Output
 mu_p_set,series,currency/MWh,0.,Shadow price of fixed power transmission p_set,Output
diff --git a/pypsa/data/variables.csv b/pypsa/data/variables.csv
index 26a8208fc..c79daa9dc 100644
--- a/pypsa/data/variables.csv
+++ b/pypsa/data/variables.csv
@@ -1,29 +1,29 @@
-component,variable,marginal_cost,spill_cost,marginal_cost_storage,nominal,handle_separately
-Generator,p,True,False,False,False,False
-Generator,status,False,False,False,False,True
-Generator,start_up,False,False,False,False,True
-Generator,shut_down,False,False,False,False,True
-Generator,p_nom,False,False,False,True,False
-Generator,n_mod,False,False,False,False,True
-Line,s,False,False,False,False,False
-Line,s_nom,False,False,False,True,False
-Line,n_mod,False,False,False,False,True
-Transformer,s,False,False,False,False,False
-Transformer,s_nom,False,False,False,True,False
-Transformer,n_mod,False,False,False,False,True
-Link,p,True,False,False,False,False
-Link,status,False,False,False,False,True
-Link,start_up,False,False,False,False,True
-Link,shut_down,False,False,False,False,True
-Link,p_nom,False,False,False,True,False
-Link,n_mod,False,False,False,False,True
-Store,e,False,False,True,False,False
-Store,p,True,False,False,False,True
-Store,e_nom,False,False,False,True,False
-Store,n_mod,False,False,False,False,True
-StorageUnit,p_dispatch,True,False,False,False,False
-StorageUnit,p_store,False,False,False,False,False
-StorageUnit,state_of_charge,False,False,True,False,False
-StorageUnit,p_nom,False,False,False,True,False
-StorageUnit,n_mod,False,False,False,False,True
-StorageUnit,spill,False,True,False,False,True
+component,variable,marginal_cost,marginal_cost_quadratic,spill_cost,marginal_cost_storage,nominal,handle_separately
+Generator,p,True,True,False,False,False,False
+Generator,status,False,False,False,False,False,True
+Generator,start_up,False,False,False,False,False,True
+Generator,shut_down,False,False,False,False,False,True
+Generator,p_nom,False,False,False,False,True,False
+Generator,n_mod,False,False,False,False,False,True
+Line,s,False,False,False,False,False,False
+Line,s_nom,False,False,False,False,True,False
+Line,n_mod,False,False,False,False,False,True
+Transformer,s,False,False,False,False,False,False
+Transformer,s_nom,False,False,False,False,True,False
+Transformer,n_mod,False,False,False,False,False,True
+Link,p,True,True,False,False,False,False
+Link,status,False,False,False,False,False,True
+Link,start_up,False,False,False,False,False,True
+Link,shut_down,False,False,False,False,False,True
+Link,p_nom,False,False,False,False,True,False
+Link,n_mod,False,False,False,False,False,True
+Store,e,False,False,False,True,False,False
+Store,p,True,True,False,False,False,True
+Store,e_nom,False,False,False,False,True,False
+Store,n_mod,False,False,False,False,False,True
+StorageUnit,p_dispatch,True,True,False,False,False,False
+StorageUnit,p_store,False,False,False,False,False,False
+StorageUnit,state_of_charge,False,False,False,True,False,False
+StorageUnit,p_nom,False,False,False,False,True,False
+StorageUnit,n_mod,False,False,False,False,False,True
+StorageUnit,spill,False,False,True,False,False,True
diff --git a/pypsa/optimization/optimize.py b/pypsa/optimization/optimize.py
index 3b6c559b4..d42280a8e 100644
--- a/pypsa/optimization/optimize.py
+++ b/pypsa/optimization/optimize.py
@@ -137,7 +137,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
             objective.append((operation * cost).sum())
 
     # marginal cost quadratic
-    for c, attr in lookup.query("marginal_cost").index:
+    for c, attr in lookup.query("marginal_cost_quadratic").index:
         if "marginal_cost_quadratic" in n.static(c):
             cost = (
                 get_as_dense(n, c, "marginal_cost_quadratic", sns)
diff --git a/pypsa/statistics/expressions.py b/pypsa/statistics/expressions.py
index f87ba0fc0..308b8b4f6 100644
--- a/pypsa/statistics/expressions.py
+++ b/pypsa/statistics/expressions.py
@@ -1035,12 +1035,13 @@ def opex(
         nice_names: bool | None = None,
         drop_zero: bool | None = None,
         round: int | None = None,
+        cost_types: str | Sequence[str] | None = None,
     ) -> pd.DataFrame:
         """
-        Calculate the ongoing operational costs.
+        Calculate the operational expenditure in the network in given currency.
 
-        Takes into account various operational parameters and their costs, measured in
-        the specified currency.
+        Operational expenditures include the marginal, marginal quadratic,
+        storage holding, spillage, start-up, shut-down and stand-by costs.
 
         Parameters
         ----------
@@ -1089,6 +1090,11 @@ def opex(
             False. Any pandas aggregation function can be used. Note that when
             aggregating the time series are aggregated to MWh using snapshot weightings.
             With False the time series is given in MW.
+        cost_types : str | Sequence[str] | None, default=None
+            List of cost types to include in the calculation. Available options
+            are: 'marginal_cost', 'marginal_cost_quadratic',
+            'marginal_cost_storage', 'spill_cost', 'start_up_cost',
+            'shut_down_cost', 'stand_by_cost'. Defaults to all (when None).
 
         Returns
         -------
@@ -1104,19 +1110,65 @@ def opex(
         Series([], dtype: float64)
 
         """
+        from pypsa.optimization.optimize import lookup
+
+        if cost_types is None:
+            cost_types_ = [
+                "marginal_cost",
+                "marginal_cost_quadratic",
+                "marginal_cost_storage",
+                "spill_cost",
+                "start_up_cost",
+                "shut_down_cost",
+                "stand_by_cost",
+            ]
+        elif isinstance(cost_types, str):
+            cost_types_ = [cost_types]
+        else:
+            cost_types_ = list(cost_types)
 
         @pass_empty_series_if_keyerror
         def func(n: Network, c: str, port: str) -> pd.Series:
-            if c in n.branch_components:
-                p = n.dynamic(c).p0
-            elif c == "StorageUnit":
-                p = n.dynamic(c).p_dispatch
-            else:
-                p = n.dynamic(c).p
-
-            opex = p * n.get_switchable_as_dense(c, "marginal_cost")
+            result = []
             weights = get_weightings(n, c)
-            return self._aggregate_timeseries(opex, weights, agg=aggregate_time)
+            weights_one = pd.Series(1.0, index=weights.index)
+            com_i = n.get_committable_i(c)
+
+            for cost_type in [
+                "marginal_cost",
+                "marginal_cost_storage",
+                "marginal_cost_quadratic",
+                "spill_cost",
+            ]:
+                if cost_type in cost_types_ and cost_type in n.static(c):
+                    attr = lookup.query(cost_type).loc[c].index.item()
+                    cost = n.get_switchable_as_dense(c, cost_type)
+                    p = n.dynamic(c)[attr]
+                    var = p * p if cost_type == "marginal_cost_quadratic" else p
+                    opex = var * cost
+                    term = self._aggregate_timeseries(opex, weights, agg=aggregate_time)
+                    result.append(term)
+
+            mapping = dict(
+                start_up_cost="start_up",
+                shut_down_cost="shut_down",
+                stand_by_cost="status",
+            )
+            for cost_type, attr in mapping.items():
+                if (
+                    cost_type in cost_types_
+                    and cost_type in n.static(c)
+                    and not com_i.empty
+                ):
+                    cost = n.get_switchable_as_dense(c, cost_type, inds=com_i)
+                    var = n.dynamic(c)[attr].loc[:, com_i]
+                    opex = var * cost
+                    w = weights if attr == "status" else weights_one
+                    term = self._aggregate_timeseries(opex, w, agg=aggregate_time)
+                    result.append(term)
+            if not result:
+                return pd.Series()
+            return pd.concat(result).groupby(level=0).sum()
 
         df = self._aggregate_components(
             func,


Test patch:
diff --git a/test/test_statistics.py b/test/test_statistics.py
index ae042a8b7..faa746f48 100644
--- a/test/test_statistics.py
+++ b/test/test_statistics.py
@@ -106,6 +106,80 @@ def test_supply_withdrawal(ac_dc_network_r):
     assert np.allclose(energy_balance.reindex(target.index), target)
 
 
+def test_opex():
+    n = pypsa.Network()
+    n.set_snapshots([0, 1, 2])
+    n.snapshot_weightings.loc[:, :] = 2
+    n.add("Bus", "bus")
+    n.add("Load", "load", bus="bus", p_set=[0, 0, 5])
+    n.add(
+        "Generator",
+        "gen",
+        bus="bus",
+        carrier="gen",
+        p_nom=10,
+        p_max_pu=[0, 1, 0],
+        marginal_cost=2,
+        marginal_cost_quadratic=0.2,
+    )
+    n.add(
+        "Store",
+        "sto",
+        bus="bus",
+        carrier="sto",
+        e_nom=10,
+        e_initial=0,
+        marginal_cost_storage=0.5,
+    )
+    n.add("Bus", "bus2")
+    n.add(
+        "StorageUnit",
+        "su",
+        bus="bus2",
+        carrier="su",
+        marginal_cost=5,
+        p_nom=1,
+        max_hours=2,
+        inflow=1,
+        spill_cost=20,
+    )
+
+    n.optimize()
+
+    opex = n.statistics.opex()
+
+    assert opex.loc["Store", "sto"] == 2 * 0.5 * 10
+    assert opex.loc["Generator", "gen"] == 2 * 2 * 5 + 2 * 0.2 * 5**2
+    assert opex.loc["StorageUnit", "su"] == 2 * 20 * 2
+
+    n.generators.marginal_cost_quadratic = 0
+    n.generators.committable = True
+    n.generators.start_up_cost = 4
+    n.generators.shut_down_cost = 5
+    n.generators.stand_by_cost = 9
+
+    n.optimize()
+
+    opex = n.statistics.opex()
+
+    assert opex.loc["Store", "sto"] == 2 * 0.5 * 10
+    assert opex.loc["Generator", "gen"] == 2 * 2 * 5 + 1 * 4 + 2 * 5 + 2 * 1 * 9
+
+    opex = n.statistics.opex(cost_types="marginal_cost")
+
+    assert opex.loc["Generator", "gen"] == 2 * 2 * 5
+    with pytest.raises(KeyError):
+        opex.loc["StorageUnit", "su"]
+    with pytest.raises(KeyError):
+        opex.loc["Store", "sto"]
+
+    opex = n.statistics.opex(cost_types="marginal_cost_storage")
+
+    assert opex.loc["Store", "sto"] == 2 * 0.5 * 10
+    with pytest.raises(KeyError):
+        opex.loc["Generator", "gen"]
+
+
 def test_no_grouping(ac_dc_network_r):
     df = ac_dc_network_r.statistics(groupby=False)
     assert not df.empty