Repository: PyPSA/PyPSA

Issue:
Exclude inactive components from model building entirely.
I'm liking the new `active` attribute, but the next step should hopefully be to exclude inactive components entirely from model building and optimisation. The impacts on memory consumption are substantial.

I've implemented a rough version of the idea in https://github.com/PyPSA/PyPSA/commit/094af75bf5d2f31ccfca39b8b4644e32a25bfb53. It was honestly a little messy, and I wonder if there's a better way of doing things. In the end, there just hasn't been the assumption throughout the code that components might not be enabled in optimisations, so there are a few places where the `active` attribute has to be dealt with more actively.

It was mentioned in https://github.com/PyPSA/PyPSA/pull/1038 that the feature I'm suggesting should be on the horizon, but possibly only after https://github.com/PyPSA/PyPSA/issues/848. My implementation didn't particularly need the latter to be implemented, but maybe it will make things easier in a way I couldn't see?

Excluding inactive components from model-building is necessary in order to get the benefit in terms of memory of build year aggregation (see https://github.com/PyPSA/pypsa-eur/pull/1056).

I will also add, however, that in testing https://github.com/PyPSA/pypsa-eur/pull/1056, I've also found that custom constraints in pypsa-eur in particular will also need to be updated to take `active` into account if this feature is implemented.


Discussion:
I haven't been following any of this at all. That being said let me drop an idea that might be very simple to implement:

At the entry point for building the model before the optimisation, create a copy of the network and remove all `active=False` components from it. Then do everything as it was until now.
After the optimisation, update the original network with the optimised network (like a `pandas.DataFrame.update(...)`), thus leaving intact the `active=False` components.
That's certainly a possibility! I would come with some memory overhead, but not all too much in the grand scheme of things (and certainly much less than the overhead of creating (unused) optimisation variables for inactive components).

Maybe what should be considered explicitly before deciding which way to go with the implementation is the following: should the `active` attribute play any role in the output of various utility functions such as `get_switchable_as_dense`, `get_active_assets`, etc.? (See also the discussion in https://github.com/PyPSA/PyPSA/pull/1038.) To me, it would make sense at the very least if `get_active_assets` respected the `active` attribute, but I would also extend that to `get_extendable_i` for example.

Downstream projects defining custom constraints (such as pypsa-eur) will have to adapt one way or the other if they decide to use `active` (which pypsa-eur should; see my PR about build year aggregation). It's always possible to manually filter out inactive components when defining constraints, but it might be easier/cleaner to rely on `get_extendable_i`, `get_active_assets` if they take care of filtering out inactive components?

I'm sure you have some thoughts on this too, @FabianHofmann 
I completely agree, this can be implemented much better. I will work on moving the whole component logic into a class soon, to remove all these arbitrary nested calls and have a unified interface to access any component data. And also close #848. This would allow for a much cleaner, but same approach as in https://github.com/PyPSA/PyPSA/commit/094af75bf5d2f31ccfca39b8b4644e32a25bfb53. 

Just a small sketch, but instead of
```python
  static = n.static(component)
  dynamic = n.dynamic(component)

  index = static.loc[n.get_active_assets(component)].index

  varying_i = dynamic[attr].columns
  fixed_i = static.index.difference(varying_i)
```
we could have something like (just changing the api)
```python
  c = n.as_component(c) # component class, passed argument can be arbitrary

  index = c.static_active.index 

  varying_i = c.dynamic[attr].columns
  fixed_i = c.static.index.difference(varying_i)
```
and introduce next do `c.dynamic` and `c.static` an view which filters for active. Not sure if this should be `c.static_active` or even 

```python
c.static.select(active_assets=True)
``` 
as a custom method on top of the DataFrame. This would also allow to define other custom filter methods which then could be easily applied and would be much cleaner. But the implementation of both is very similar. Just some ideas.

While being a good hot fix, the filtered down network copy just brings more chaos and doesn't allow for granular changes. So if this has time, I would wait until the changes above are implemented.

> To me, it would make sense at the very least if `get_active_assets` respected the `active` attribute, but I would also extend that to `get_extendable_i` for example.

`get_active_assets` respects the `active` attribute. But I agree, this needs to be nailed down for all utility functions. Which makes me like this generalised way of filtering on components even more.
It looks like I'm the only one who has raised the memory issues with myopic optimisation in pypsa-eur; I'll keep using the built year aggregation implementation from before `active` if needed. So not a huge rush from my side. Looks like the implementation will look a lot better with the suggested improvements!



Patch:
diff --git a/doc/references/release-notes.rst b/doc/references/release-notes.rst
index deb6a928c..38a342966 100644
--- a/doc/references/release-notes.rst
+++ b/doc/references/release-notes.rst
@@ -11,6 +11,7 @@ Release Notes
 ..    To use the features already you have to install the ``master`` branch, e.g. 
 ..    ``pip install git+https://github.com/pypsa/pypsa``.
 
+
 Features
 --------
 
@@ -25,6 +26,11 @@ Features
 
   where :math:`r` is the discount rate and :math:`n` is the lifetime in years.
 
+* Inactive components (see pypsa.Components.inactive_assets) are now excluded from the
+  the optimization model entirely. This has no effect on the results, but it can
+  reduce the memory footprint when solving the model.
+
+
 * The option to set bus-level capacity expansion limits per carrier via `Bus`
   attributes `nom_{min/max}_{carrier}_{period}` is now deprecated. The global
   constraint type `"tech_capacity_expansion_limit"` offers identical functionality.
diff --git a/pypsa/components/descriptors.py b/pypsa/components/descriptors.py
index 92b2a3b51..385b4acc5 100644
--- a/pypsa/components/descriptors.py
+++ b/pypsa/components/descriptors.py
@@ -77,7 +77,7 @@ def get_active_assets(
         self,
         investment_period: int | str | Sequence | None = None,
     ) -> pd.Series:
-        """Get active components mask of componen type in investment period(s).
+        """Get active components mask of component type in investment period(s).
 
         A component is considered active when:
 
@@ -143,6 +143,73 @@ def get_active_assets(
             )
         return pd.DataFrame(active).any(axis=1) & self.static.active
 
+    @property
+    def active_assets(self) -> pd.Series:
+        """Get list of active assets.
+
+        See corresponding [pypsa.Components.inactive_assets][] for details.
+
+        Returns
+        -------
+        pd.Series
+            List of inactive assets
+
+        See Also
+        --------
+        [pypsa.Components.inactive_assets][]
+
+        """
+        active_assets = self.get_active_assets()
+        return active_assets[active_assets].index.get_level_values("name").unique()
+
+    @property
+    def inactive_assets(self) -> pd.Series:
+        """Get list of inactive assets.
+
+        An asset is considered inactive when one of the following conditions is met:
+        - `active` is set to False across all dimensions (investment periods, scenarios)
+        - `build_year` + `lifetime` never satisfies the condition for any investment period
+
+        Inactive assets are not considered in the optimization and are excluded from
+        the model entirely.
+
+        Returns
+        -------
+        pd.Series
+            List of inactive assets
+
+        Examples
+        --------
+        >>> n = pypsa.Network()
+        >>> n.snapshots = pd.MultiIndex.from_product([[2020, 2021, 2022], ["1", "2", "3"]])
+        >>> n.add("Generator", "g1", build_year=2020, lifetime=1)
+        Index(['g1'], dtype='object')
+        >>> n.add("Generator", "g2", active=False)
+        Index(['g2'], dtype='object')
+
+        List all components
+        >>> n.generators.index
+        Index(['g1', 'g2'], dtype='object', name='name')
+
+        List of inactive components
+        'g1' will be considered as active because it is active in at least one investment period
+        >>> n.components.generators.inactive_assets
+        Index(['g2'], dtype='object', name='name')
+
+        List of active components
+        >>> n.components.generators.active_assets
+        Index(['g1'], dtype='object', name='name')
+
+        `c.active_assets` and `c.inactive_assets` are mutually exclusive
+
+        See Also
+        --------
+        [pypsa.Components.get_active_assets][]
+
+        """
+        active_assets = self.get_active_assets()
+        return active_assets[~active_assets].index.get_level_values("name").unique()
+
     def get_activity_mask(
         self,
         sns: Sequence | None = None,
diff --git a/pypsa/optimization/abstract.py b/pypsa/optimization/abstract.py
index 0bce97572..f1db87f73 100644
--- a/pypsa/optimization/abstract.py
+++ b/pypsa/optimization/abstract.py
@@ -177,7 +177,7 @@ def optimize_transmission_expansion_iteratively(
         n = self._n
 
         n.lines["carrier"] = n.lines.bus0.map(n.buses.carrier)
-        ext_i = n.components["Line"].extendables.copy()
+        ext_i = n.c.lines.extendables.difference(n.c.lines.inactive_assets)
         typed_i = n.lines.query('type != ""').index
         ext_untyped_i = ext_i.difference(typed_i)
         ext_typed_i = ext_i.intersection(typed_i)
diff --git a/pypsa/optimization/common.py b/pypsa/optimization/common.py
index 81252599f..0c0d24803 100644
--- a/pypsa/optimization/common.py
+++ b/pypsa/optimization/common.py
@@ -40,14 +40,21 @@ def reindex(ds: xr.DataArray, dim: str, index: pd.Index) -> xr.DataArray:
     return ds.sel({dim: index}).rename({dim: index.name})
 
 
-def _set_dynamic_data(n: Network, c: str, attr: str, df: pd.DataFrame) -> None:
+def _set_dynamic_data(n: Network, component: str, attr: str, df: pd.DataFrame) -> None:
     """Update values in time-dependent attribute from new dataframe."""
-    dynamic = n.dynamic(c)
-    if (attr not in dynamic) or (dynamic[attr].empty):
-        dynamic[attr] = df.reindex(n.snapshots).fillna(0.0)
+    c = n.components[component]
+    if (attr not in c.dynamic) or (c.dynamic[attr].empty):
+        c.dynamic[attr] = df.reindex(n.snapshots)
+
     else:
-        dynamic[attr].loc[df.index, df.columns] = df
-        dynamic[attr] = dynamic[attr].fillna(0.0)
+        c.dynamic[attr].loc[df.index, df.columns] = df
+
+    c.dynamic[attr] = (
+        c.dynamic[attr]
+        .reindex(n.snapshots, level="snapshot", axis=0)
+        .reindex(c.component_names, level="name", axis=1)
+        .fillna(0.0)
+    )
 
 
 def get_strongly_meshed_buses(n: Network, threshold: int = 45) -> pd.Series:
diff --git a/pypsa/optimization/constraints.py b/pypsa/optimization/constraints.py
index e3c5cec17..079e45483 100644
--- a/pypsa/optimization/constraints.py
+++ b/pypsa/optimization/constraints.py
@@ -78,8 +78,7 @@ def define_operational_constraints_for_non_extendables(
 
     """
     c = as_components(n, component)
-    fix_i = c.fixed
-    fix_i = fix_i.difference(c.committables)
+    fix_i = c.fixed.difference(c.committables).difference(c.inactive_assets)
 
     if fix_i.empty:
         return
@@ -153,7 +152,7 @@ def define_operational_constraints_for_extendables(
     c = as_components(n, component)
     sns = as_index(n, sns, "snapshots")
 
-    ext_i = c.extendables
+    ext_i = c.extendables.difference(c.inactive_assets)
     if ext_i.empty:
         return
     if isinstance(ext_i, pd.MultiIndex):
@@ -236,7 +235,7 @@ def define_operational_constraints_for_committables(
 
     """
     c = as_components(n, component)
-    com_i = c.committables
+    com_i = c.committables.difference(c.inactive_assets)
 
     if com_i.empty:
         return
@@ -515,7 +514,7 @@ def define_nominal_constraints_for_extendables(
 
     """
     c = as_components(n, component)
-    ext_i = c.extendables
+    ext_i = c.extendables.difference(c.inactive_assets)
 
     if ext_i.empty:
         return
@@ -604,10 +603,10 @@ def define_ramp_limit_constraints(
     p = m[f"{c.name}-{attr}"]
 
     # Get different component groups for constraint application
-    com_i = c.committables
-    fix_i = c.fixed
+    com_i = c.committables.difference(c.inactive_assets)
+    fix_i = c.fixed.difference(c.inactive_assets)
     fix_i = fix_i.difference(com_i).rename(fix_i.name)
-    ext_i = c.extendables
+    ext_i = c.extendables.difference(c.inactive_assets)
 
     # Auxiliary variables for constraint application
     ext_dim = ext_i.name if ext_i.name else c.name
@@ -912,6 +911,7 @@ def define_nodal_balance_constraints(
         expr = sign * m[f"{c.name}-{attr}"]
 
         cbuses = c._as_xarray(column)
+        cbuses = cbuses.sel(name=c.active_assets)
         # Only keep the first scenario if there are multiple
         if n.has_scenarios:
             cbuses = cbuses.isel(scenario=0, drop=True)
@@ -940,7 +940,9 @@ def define_nodal_balance_constraints(
             dims=["snapshot", "name"],
         )
     else:
-        loads_values = loads.da.p_set.where(loads.da.active.sel(snapshot=sns))
+        loads_values = loads.da.p_set.where(
+            loads.da.active.sel(name=loads.active_assets, snapshot=sns)
+        )
         loads_values = loads_values.reindex(name=loads.static.index.unique("name"))
         load_buses = loads._as_xarray("bus").rename("Bus")
         if n.has_scenarios:
@@ -1032,7 +1034,10 @@ def define_kirchhoff_voltage_constraints(n: Network, sns: pd.Index) -> None:
         exprs = []
         for c in C.index.unique("type"):
             C_branch = DataArray(C.loc[c])
-            flow = m[f"{c}-s"].sel(snapshot=snapshots, name=C_branch.indexes["name"])
+            flow = m[f"{c}-s"].sel(
+                snapshot=snapshots,
+                name=C_branch.indexes["name"].difference(n.c[c].inactive_assets),
+            )
             exprs.append(flow @ C_branch * 1e5)
         lhs.append(sum(exprs))
 
@@ -1193,7 +1198,7 @@ def define_fixed_operation_constraints(
     if attr_set not in c.dynamic.keys() or c.dynamic[attr_set].empty:
         return
 
-    fix = c.da[attr_set].sel(snapshot=sns)
+    fix = c.da[attr_set].sel(snapshot=sns, name=c.active_assets)
 
     if fix.isnull().all():
         return
@@ -1251,7 +1256,7 @@ def define_storage_unit_constraints(n: Network, sns: pd.Index) -> None:
     component = "StorageUnit"
     dim = "snapshot"
     c = as_components(n, component)
-    active = c.da.active.sel(snapshot=sns)
+    active = c.da.active.sel(snapshot=sns, name=c.active_assets)
 
     if c.static.empty:
         return
@@ -1395,21 +1400,21 @@ def define_store_constraints(n: Network, sns: pd.Index) -> None:
     component = "Store"
     dim = "snapshot"
     c = as_components(n, component)
-    active = c.da.active.sel(snapshot=sns)
+    active = c.da.active.sel(snapshot=sns, name=c.active_assets)
 
     if c.static.empty:
         return
 
     # elapsed hours
-    elapsed_h = expand_series(n.snapshot_weightings.stores[sns], c.static.index)
+    elapsed_h = expand_series(n.snapshot_weightings.stores[sns], c.active_assets)
     eh = DataArray(elapsed_h)
 
     # Unstack in stochastic networks with MultiIndex snapshots
-    if n.has_scenarios:
+    if n.has_scenarios and "dim_1" in eh.dims:
         eh = eh.unstack("dim_1")
 
     # standing efficiency
-    eff_stand = (1 - c.da.standing_loss.sel(snapshot=sns)) ** eh
+    eff_stand = (1 - c.da.standing_loss.sel(snapshot=sns, name=c.active_assets)) ** eh
 
     e = m[f"{component}-e"]
     p = m[f"{component}-p"]
@@ -1419,7 +1424,7 @@ def define_store_constraints(n: Network, sns: pd.Index) -> None:
 
     # We create a mask `include_previous_e` which excludes the first snapshot
     # for non-cyclic assets
-    noncyclic_b = ~c.da.e_cyclic
+    noncyclic_b = ~c.da.e_cyclic.sel(name=c.active_assets)
     include_previous_e = (active.cumsum(dim) != 1).where(noncyclic_b, True)
 
     # Calculate previous energy state with proper handling of boundaries
@@ -1428,14 +1433,16 @@ def define_store_constraints(n: Network, sns: pd.Index) -> None:
     )
 
     # We add initial e for non-cyclic assets to rhs
-    e_init = c.da.e_initial
+    e_init = c.da.e_initial.sel(name=c.active_assets)
     rhs = DataArray(0)
 
     if isinstance(sns, pd.MultiIndex):
         # If multi-horizon optimization, we update previous_e and the rhs
         # for all assets which are cyclic/non-cyclic per period
         periods = e.coords["period"]
-        per_period = c.da.e_cyclic_per_period | c.da.e_initial_per_period
+        per_period = c.da.e_cyclic_per_period.sel(
+            name=c.active_assets
+        ) | c.da.e_initial_per_period.sel(name=c.active_assets)
 
         # We calculate the previous e per period while cycling within a period
         # Normally, we should use groupby, but it's broken for multi-index
@@ -1517,7 +1524,7 @@ def define_loss_constraints(
         return
 
     tangents = transmission_losses
-    active = c.da.active.sel(snapshot=sns)
+    active = c.da.active.sel(snapshot=sns, name=c.active_assets)
 
     s_max_pu = c.da.s_max_pu.sel(snapshot=sns)
 
diff --git a/pypsa/optimization/global_constraints.py b/pypsa/optimization/global_constraints.py
index 333bc1da9..0d52fb680 100644
--- a/pypsa/optimization/global_constraints.py
+++ b/pypsa/optimization/global_constraints.py
@@ -56,9 +56,8 @@ def define_tech_capacity_expansion_limit(n: Network, sns: Sequence) -> None:
             if "carrier" not in static:
                 continue
 
-            ext_i = n.components[c].extendables.intersection(
-                static.index[static.carrier == carrier]
-            )
+            ext_i = n.components[c].extendables.difference(n.c[c].inactive_assets)
+            ext_i = ext_i.intersection(static.index[static.carrier == carrier])
             if period is not None:
                 ext_i = ext_i[n.get_active_assets(c, period)[ext_i]]
 
@@ -155,9 +154,8 @@ def define_nominal_constraints_per_bus_carrier(n: Network, sns: pd.Index) -> Non
             if c not in n.one_port_components or "carrier" not in static:
                 continue
 
-            ext_i = n.components[c].extendables.intersection(
-                static.index[static.carrier == carrier]
-            )
+            ext_i = n.c[c].extendables.difference(n.c[c].inactive_assets)
+            ext_i = ext_i.intersection(static.index[static.carrier == carrier])
             if period is not None:
                 ext_i = ext_i[n.get_active_assets(c, period)[ext_i]]
 
@@ -229,7 +227,9 @@ def define_growth_limit(n: Network, sns: pd.Index) -> None:
             carrier_map = component_carriers
 
         carriers_match = unique_component_names[carrier_map.isin(carrier_i)]
-        limited_names = carriers_match.intersection(n.components[c].extendables)
+        limited_names = carriers_match.intersection(
+            n.c[c].extendables.difference(n.c[c].inactive_assets)
+        )
 
         if limited_names.empty:
             continue
@@ -430,7 +430,7 @@ def define_operational_limit(n: Network, sns: pd.Index) -> None:
             lhs = []
 
             # generators
-            gens = n.generators.query("carrier == @glc.carrier_attribute")
+            gens = n.generators.query("carrier == @glc.carrier_attribute and active")
             if not gens.empty:
                 gens = gens.loc[scenario]
                 p = m["Generator-p"].sel(name=gens.index, snapshot=sns[sns_sel])
@@ -442,7 +442,7 @@ def define_operational_limit(n: Network, sns: pd.Index) -> None:
                 lhs.append(expr)
 
             # storage units (non-cyclic): subtract end SoC and add initial SoC as constant
-            cond = "carrier == @glc.carrier_attribute and not cyclic_state_of_charge"
+            cond = "carrier == @glc.carrier_attribute and not cyclic_state_of_charge and active"
             sus = n.storage_units.query(cond)
             if not sus.empty:
                 sus = sus.loc[scenario]
@@ -456,7 +456,7 @@ def define_operational_limit(n: Network, sns: pd.Index) -> None:
 
             # stores (non-cyclic): subtract end e and add initial e as constant
             stores = n.stores.query(
-                "carrier == @glc.carrier_attribute and not e_cyclic"
+                "carrier == @glc.carrier_attribute and not e_cyclic and active"
             )
             if not stores.empty:
                 stores = stores.loc[scenario]
@@ -542,7 +542,7 @@ def substr(s: str) -> str:
                 attr = nominal_attrs[c]
 
                 # Start from extendable components by name
-                ext_all = n.components[c].extendables
+                ext_all = n.c[c].extendables.difference(n.c[c].inactive_assets)
                 if ext_all.empty:
                     continue
 
@@ -644,7 +644,7 @@ def substr(s: str) -> str:
         for c in ["Line", "Link"]:
             attr = nominal_attrs[c]
 
-            ext_i = n.components[c].extendables
+            ext_i = n.components[c].extendables.difference(n.c[c].inactive_assets)
             if ext_i.empty:
                 continue
 
diff --git a/pypsa/optimization/optimize.py b/pypsa/optimization/optimize.py
index 4939add4b..c9021b626 100644
--- a/pypsa/optimization/optimize.py
+++ b/pypsa/optimization/optimize.py
@@ -123,7 +123,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
 
     for c_name, attr in nom_attr:
         c = as_components(n, c_name)
-        ext_i = c.extendables
+        ext_i = c.extendables.difference(c.inactive_assets)
 
         if ext_i.empty:
             continue
@@ -186,7 +186,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
             if var_name not in m.variables and cost_type == "spill_cost":
                 continue
 
-            cost = c.da[cost_type].sel(snapshot=sns)
+            cost = c.da[cost_type].sel(snapshot=sns, name=c.active_assets)
             if cost.size == 0 or (cost == 0).all():
                 continue
 
@@ -217,7 +217,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
     # stand-by cost
     for c_name in ["Generator", "Link"]:
         c = as_components(n, c_name)
-        com_i = c.committables
+        com_i = c.committables.difference(c.inactive_assets)
 
         if com_i.empty:
             continue
@@ -236,7 +236,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
     # investment
     for c_name, attr in nominal_attrs.items():
         c = as_components(n, c_name)
-        ext_i = c.extendables
+        ext_i = c.extendables.difference(c.inactive_assets)
 
         if ext_i.empty:
             continue
@@ -264,7 +264,7 @@ def define_objective(n: Network, sns: pd.Index) -> None:
     keys = ["start_up", "shut_down"]  # noqa: F841
     for c_name, attr in lookup.query("variable in @keys").index:
         c = as_components(n, c_name)
-        com_i = c.committables
+        com_i = c.committables.difference(c.inactive_assets)
 
         if com_i.empty:
             continue
@@ -853,12 +853,11 @@ def fix_optimal_capacities(self) -> None:
         already performed and a operational optimization should be done
         afterwards.
         """
+        n = self._n
         for c, attr in nominal_attrs.items():
-            ext_i = self._n.components[c].extendables
-            self._n.static(c).loc[ext_i, attr] = self._n.static(c).loc[
-                ext_i, attr + "_opt"
-            ]
-            self._n.static(c)[attr + "_extendable"] = False
+            ext_i = n.c[c].extendables.difference(n.c[c].inactive_assets)
+            n.static(c).loc[ext_i, attr] = n.static(c).loc[ext_i, attr + "_opt"]
+            n.static(c)[attr + "_extendable"] = False
 
     def fix_optimal_dispatch(self) -> None:
         """Fix dispatch of all assets to optimized values.
diff --git a/pypsa/optimization/variables.py b/pypsa/optimization/variables.py
index 8da882519..c096132c2 100644
--- a/pypsa/optimization/variables.py
+++ b/pypsa/optimization/variables.py
@@ -37,7 +37,7 @@ def define_operational_variables(
     if c.empty:
         return
 
-    active = c.da.active.sel(snapshot=sns)
+    active = c.da.active.sel(name=c.active_assets, snapshot=sns)
     coords = active.coords
     n.model.add_variables(coords=coords, name=f"{c.name}-{attr}", mask=active)
 
@@ -60,7 +60,7 @@ def define_status_variables(
 
     """
     c = n.components[c_name]
-    com_i = c.committables
+    com_i = c.committables.difference(c.inactive_assets)
 
     if com_i.empty:
         return
@@ -92,7 +92,7 @@ def define_start_up_variables(
 
     """
     c = n.components[c_name]
-    com_i = c.committables
+    com_i = c.committables.difference(c.inactive_assets)
 
     if com_i.empty:
         return
@@ -128,7 +128,7 @@ def define_shut_down_variables(
 
     """
     c = n.components[c_name]
-    com_i = c.committables
+    com_i = c.committables.difference(c.inactive_assets)
 
     if com_i.empty:
         return
@@ -160,7 +160,7 @@ def define_nominal_variables(n: Network, c_name: str, attr: str) -> None:
 
     """
     c = n.components[c_name]
-    ext_i = c.extendables
+    ext_i = c.extendables.difference(c.inactive_assets)
     if ext_i.empty:
         return
     if isinstance(ext_i, pd.MultiIndex):
@@ -186,6 +186,7 @@ def define_modular_variables(n: Network, c_name: str, attr: str) -> None:
     """
     c = n.components[c_name]
     mod_i = c.static.query(f"{attr}_extendable and ({attr}_mod>0)").index
+    mod_i = mod_i.difference(c.inactive_assets)
 
     if mod_i.empty:
         return
@@ -201,11 +202,11 @@ def define_spillage_variables(n: Network, sns: Sequence) -> None:
     if c.empty:
         return
 
-    upper = c.da.inflow.sel(snapshot=sns)
+    upper = c.da.inflow.sel(name=c.active_assets, snapshot=sns)
     if (upper.max() <= 0).all():
         return
 
-    active = c.da.active.sel(snapshot=sns)
+    active = c.da.active.sel(snapshot=sns, name=c.active_assets)
 
     active_aligned, upper_aligned = xr.align(active, upper, join="inner")
     active = active_aligned.where(upper_aligned > 0, False)
@@ -230,6 +231,6 @@ def define_loss_variables(n: Network, sns: Sequence, c_name: str) -> None:
     if c.empty or c.name not in n.passive_branch_components:
         return
 
-    active = c.da.active.sel(snapshot=sns)
+    active = c.da.active.sel(name=c.active_assets, snapshot=sns)
     coords = active.coords
     n.model.add_variables(0, coords=coords, name=f"{c.name}-loss", mask=active)


Test patch:
diff --git a/test/test_lopf_activity_mask.py b/test/test_lopf_activity_mask.py
index d0489863d..52cb1ea00 100644
--- a/test/test_lopf_activity_mask.py
+++ b/test/test_lopf_activity_mask.py
@@ -1,6 +1,9 @@
 import numpy as np
+import pandas as pd
 import pytest
 
+import pypsa
+
 
 def test_optimize(ac_dc_network):
     n = ac_dc_network
@@ -15,6 +18,17 @@ def test_optimize(ac_dc_network):
 
     assert n.links_t.p0.loc[:, inactive_links].eq(0).all().all()
 
+    assert "Bremen Converter" in n.model.variables["Link-p_nom"].coords["name"]
+    assert "DC link" not in n.model.variables["Link-p_nom"].coords["name"]
+
+    assert "Bremen Converter" in n.model.variables["Link-p"].coords["name"]
+    assert "DC link" not in n.model.variables["Link-p"].coords["name"]
+
+    assert (
+        "Bremen Converter" in n.model.constraints["Link-ext-p_nom-lower"].coords["name"]
+    )
+    assert "DC link" not in n.model.constraints["Link-ext-p_nom-lower"].coords["name"]
+
 
 def test_optimize_with_power_flow(scipy_network):
     """
@@ -66,3 +80,257 @@ def test_scenario(line_active):
 
     test_scenario(True)
     test_scenario(False)
+
+
+def test_generators_with_active_attribute():
+    """Test generators with active=False attribute."""
+    n = pypsa.Network()
+
+    n.set_snapshots(pd.date_range("2023-01-01", periods=2, freq="h"))
+
+    n.add("Bus", "bus", carrier="AC")
+    n.add("Carrier", "AC")
+
+    n.add("Load", "load", bus="bus", p_set=100)
+
+    # Add one active generator to meet demand
+    n.add(
+        "Generator", "gen_active", bus="bus", p_nom=200, marginal_cost=20, active=True
+    )
+
+    # Add one inactive generator
+    n.add(
+        "Generator",
+        "gen_inactive",
+        bus="bus",
+        p_nom=150,
+        marginal_cost=10,
+        active=False,
+    )
+
+    status, _ = n.optimize()
+
+    assert status == "ok"
+
+    # Inactive generator should have zero output
+    assert (n.generators_t.p["gen_inactive"] == 0).all()
+
+    # Inactive generator should not be in model variables
+    assert "gen_inactive" not in n.model.variables["Generator-p"].coords["name"]
+    assert "gen_active" in n.model.variables["Generator-p"].coords["name"]
+
+
+def test_stores_with_active_attribute():
+    """Test stores with active=False attribute."""
+    n = pypsa.Network()
+
+    n.set_snapshots(pd.date_range("2023-01-01", periods=3, freq="h"))
+
+    n.add("Bus", "bus0", carrier="AC")
+    n.add("Bus", "bus1", carrier="AC")
+    n.add("Carrier", "electricity")
+    n.add("Carrier", "AC")
+
+    n.add("Load", "load", bus="bus0", p_set=100)
+    n.add(
+        "Generator",
+        "gen",
+        bus="bus0",
+        p_nom=200,
+        marginal_cost=10,
+        carrier="electricity",
+    )
+
+    # Add stores with different active states
+    n.add(
+        "Store",
+        "store_active",
+        bus="bus0",
+        e_nom=500,
+        e_cyclic=True,
+        active=True,
+        carrier="electricity",
+    )
+    n.add(
+        "Store",
+        "store_inactive",
+        bus="bus1",
+        e_nom=300,
+        e_cyclic=False,
+        active=False,
+        carrier="electricity",
+    )
+
+    status, _ = n.optimize()
+
+    assert status == "ok"
+
+    # Inactive store should have zero flow
+    assert (n.stores_t.p["store_inactive"] == 0).all()
+
+    # Inactive store should not be in model variables
+    assert "store_inactive" not in n.model.variables["Store-p"].coords["name"]
+    assert "store_active" in n.model.variables["Store-p"].coords["name"]
+
+
+def test_mixed_components_with_active_attribute():
+    """Test mixed component types with active=False attributes."""
+    n = pypsa.Network()
+
+    n.set_snapshots(pd.date_range("2023-01-01", periods=3, freq="h"))
+
+    # Create buses
+    for i in range(3):
+        n.add("Bus", f"bus{i}", carrier="AC")
+
+    n.add("Carrier", "electricity")
+    n.add("Carrier", "AC")
+
+    n.add("Load", "load", bus="bus0", p_set=100)
+
+    # Add generators - some active, some not
+    n.add(
+        "Generator",
+        "gen_active",
+        bus="bus0",
+        p_nom=150,
+        marginal_cost=30,
+        carrier="electricity",
+        active=True,
+    )
+    n.add(
+        "Generator",
+        "gen_inactive",
+        bus="bus1",
+        p_nom=120,
+        marginal_cost=25,
+        carrier="electricity",
+        active=False,
+    )
+
+    # Add links - some active, some not
+    n.add(
+        "Link",
+        "link_active",
+        bus0="bus1",
+        bus1="bus2",
+        p_nom=90,
+        marginal_cost=5,
+        active=True,
+        carrier="electricity",
+    )
+    n.add(
+        "Link",
+        "link_inactive",
+        bus0="bus0",
+        bus1="bus1",
+        p_nom=70,
+        marginal_cost=2,
+        active=False,
+        carrier="electricity",
+    )
+
+    # Add stores - some active, some not
+    n.add(
+        "Store",
+        "store_active",
+        bus="bus0",
+        e_nom=300,
+        e_cyclic=True,
+        marginal_cost=1,
+        active=True,
+        carrier="electricity",
+    )
+    n.add(
+        "Store",
+        "store_inactive",
+        bus="bus1",
+        e_nom=200,
+        e_cyclic=False,
+        marginal_cost=3,
+        active=False,
+        carrier="electricity",
+    )
+
+    status, _ = n.optimize()
+
+    assert status == "ok"
+
+    # Test inactive components have zero flows
+    assert (n.generators_t.p["gen_inactive"] == 0).all()
+    assert (n.links_t.p0["link_inactive"] == 0).all()
+    assert (n.stores_t.p["store_inactive"] == 0).all()
+
+    # Test inactive components not in model variables
+    assert "gen_inactive" not in n.model.variables["Generator-p"].coords["name"]
+    assert "link_inactive" not in n.model.variables["Link-p"].coords["name"]
+    assert "store_inactive" not in n.model.variables["Store-p"].coords["name"]
+
+
+def test_inactive_stores_with_global_operational_limit():
+    """Test that inactive stores are excluded from global operational limits."""
+    n = pypsa.Network()
+
+    n.set_snapshots(pd.date_range("2023-01-01", periods=3, freq="h"))
+
+    n.add("Bus", "bus0", carrier="AC")
+    n.add("Bus", "bus1", carrier="AC")
+    n.add("Carrier", "electricity")
+    n.add("Carrier", "AC")
+
+    n.add("Load", "load", bus="bus0", p_set=100)
+    n.add(
+        "Generator",
+        "gen",
+        bus="bus0",
+        p_nom=200,
+        marginal_cost=10,
+        carrier="electricity",
+    )
+
+    # Add active and inactive stores with the same carrier
+    n.add(
+        "Store",
+        "store_active",
+        bus="bus0",
+        e_nom=500,
+        e_cyclic=False,
+        e_initial=100,
+        active=True,
+        carrier="electricity",
+    )
+    n.add(
+        "Store",
+        "store_inactive",
+        bus="bus1",
+        e_nom=300,
+        e_cyclic=False,
+        e_initial=50,
+        active=False,
+        carrier="electricity",
+    )
+
+    # Add global operational limit for electricity carrier
+    n.add(
+        "GlobalConstraint",
+        "electricity_limit",
+        type="operational_limit",
+        carrier_attribute="electricity",
+        sense="<=",
+        constant=1000,  # Generous limit to allow optimization to succeed
+    )
+
+    status, _ = n.optimize()
+
+    assert status == "ok"
+
+    # Test inactive store has zero flow
+    assert (n.stores_t.p["store_inactive"] == 0).all()
+
+    # Test inactive store is not in model variables
+    assert "store_inactive" not in n.model.variables["Store-p"].coords["name"]
+    assert "store_active" in n.model.variables["Store-p"].coords["name"]
+
+    # Test inactive store is not in model constraints related to energy
+    assert "store_inactive" not in n.model.variables["Store-e"].coords["name"]
+    assert "store_active" in n.model.variables["Store-e"].coords["name"]