Repository: PyPSA/PyPSA

Issue:
Multi-period network: export_to_netcdf() loses investment periods after extending snapshots
## Bug Summary

When a multi-period network is created by extending snapshots with `set_snapshots()`, `export_to_netcdf()` fails to save all investment periods. The internal `_investment_periods_data` DataFrame is not synchronized when snapshots are extended, causing only the original periods to be exported.

## Steps to Reproduce

```python
import pandas as pd
import pypsa
import xarray as xr

# 1. Create single-period network
n = pypsa.Network()
n.set_snapshots(pd.date_range("2013-03-01", periods=3, freq="D"))
n.add("Bus", "bus0")
n.add("Generator", "gen0", bus="bus0", p_nom=100)

# 2. Convert to multi-period with period 0
snapshots_multi = pd.MultiIndex.from_product([[0], n.snapshots], names=["period", "timestep"])
n.set_snapshots(snapshots_multi)
n.set_investment_periods([0])

# 3. Extend snapshots to add period 2040
new_snapshots = pd.date_range("2013-03-01", periods=3, freq="D")
extended = pd.MultiIndex.from_tuples(
    list(n.snapshots) + [(2040, t) for t in new_snapshots],
    names=["period", "timestep"]
)
n.set_snapshots(extended)

# 4. Check state before export
print(f"n.investment_periods: {list(n.investment_periods)}")  # [0, 2040] ✓
print(f"_investment_periods_data.index: {n._investment_periods_data.index.tolist()}")  # [0] ✗

# 5. Export and verify
n.export_to_netcdf("test.nc")
ds = xr.open_dataset("test.nc")
print(f"Saved investment_periods: {ds.coords['investment_periods'].values}")  # [0] ✗
```

**Expected**: `investment_periods` coordinate contains `[0, 2040]`  
**Actual**: `investment_periods` coordinate contains only `[0]`

## Root Cause

PyPSA has two representations of investment periods:

1. **`investment_periods` property** (`network/index.py:453-462`): Dynamically extracts periods from snapshots MultiIndex
2. **`_investment_periods_data` DataFrame** (`network/index.py:640`): Stores investment period weightings

When `set_snapshots()` is called to extend the snapshot MultiIndex:
- The `investment_periods` property correctly returns `[0, 2040]` (extracted from snapshots)
- BUT `_investment_periods_data.index` remains `[0]` (not automatically synchronized)

The export function uses `_investment_periods_data` (`network/io.py:1199-1201`):
```python
if self.has_periods:
    investment_periods = self.investment_period_weightings  # Returns _investment_periods_data
    exporter.save_investment_periods(investment_periods)
```

Only periods in `_investment_periods_data.index` are exported, causing data loss.

## Workaround

Explicitly call `set_investment_periods()` after extending snapshots:

```python
n.set_snapshots(extended_snapshots)
snapshot_periods = list(n.snapshots.get_level_values("period").unique())
n.set_investment_periods(snapshot_periods)  # Force synchronization
```

This works, but is non-obvious since `n.investment_periods` already appears correct.

## Proposed Solution

Option 1 (Recommended): Make `set_snapshots()` automatically synchronize `_investment_periods_data` when snapshots have a period level.

Option 2: Add validation in `export_to_netcdf()` to detect and warn about mismatches.

Option 3: Document that `set_investment_periods()` must be called after `set_snapshots()`.

## Environment

- PyPSA: 1.0.0
- pandas: 2.3.2
- Python: 3.12
- Platform: Linux

## Related Code Locations

- `investment_periods` property: `pypsa/network/index.py:453-462`
- `periods` property (extracts from snapshots): `pypsa/network/index.py:403-405`
- `set_investment_periods()`: `pypsa/network/index.py:280-358`
- Export logic: `pypsa/network/io.py:1199-1201`
- netCDF exporter: `pypsa/network/io.py:994-998`

## Additional Context

This issue was discovered while implementing perfect foresight optimization in PyPSA-Eur, where networks are built incrementally by concatenating multiple investment horizons. The bug is silent - networks appear correct in memory but lose data on export.

Discussion:

Fix (patch):
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 54df2ea00..c1fb0c195 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -15,8 +15,11 @@ SPDX-License-Identifier: CC-BY-4.0
 
 ### Bug Fixes
 
+- Fix [`n.set_snapshots`][pypsa.Network.set_snapshots] to synchronize investment period weightings when snapshots with new periods are set. (<!-- md:pr 1414 -->).
+
 - Fix scenario extraction methods ([pypsa.Network.get_scenario][] and [`n['scenario']`][pypsa.Network.__getitem__]) to properly handle empty components (<!-- md:pr 1402 -->).
-- Fix bus validation when adding global constraints via [pypsa.Network.add][], eliminating false warnings for GlobalConstraints.
+
+- Fix bus validation when adding global constraints via [pypsa.Network.add][], eliminating false warnings for GlobalConstraints. (<!-- md:pr 1413 -->).
 
 ## [**v1.0.0**](https://github.com/PyPSA/PyPSA/releases/tag/v1.0.0) <small>14 October 2025</small> 🎉 { id="v1.0.0" } 
 
diff --git a/pypsa/network/index.py b/pypsa/network/index.py
index f30b8b142..99a760150 100644
--- a/pypsa/network/index.py
+++ b/pypsa/network/index.py
@@ -146,7 +146,13 @@ def set_snapshots(
                 else:
                     dynamic[k] = dynamic[k].reindex(self.snapshots)
 
-        # NB: No need to rebind dynamic to self, since haven't changed it
+        # Synchronize investment_periods_data when snapshots have a period level
+        if isinstance(sns, pd.MultiIndex):
+            self.investment_period_weightings = (
+                self.investment_period_weightings.reindex(
+                    self.periods, fill_value=1.0
+                ).astype(float)
+            )
 
     @property
     def snapshots(self) -> pd.Index | pd.MultiIndex:


Test changes:
diff --git a/test/test_bugs.py b/test/test_bugs.py
index be9d6cfb3..81aab1f4e 100644
--- a/test/test_bugs.py
+++ b/test/test_bugs.py
@@ -191,3 +191,47 @@ def test_1319():
     n_copy = n.copy()  # Should not raise an error
     assert n_copy is not n
     assert len(n_copy.buses) == len(n.c.buses.static)
+
+
+def test_1411():
+    """
+    Investment periods should sync when setting snapshots with MultiIndex.
+    See https://github.com/PyPSA/PyPSA/issues/1411.
+    """
+    # Test 1: Setting MultiIndex snapshots directly on fresh network
+    n = pypsa.Network()
+    n.add("Bus", "bus0")
+    timesteps = pd.date_range("2013-03-01", periods=3, freq="D")
+    snapshots = pd.MultiIndex.from_product(
+        [[2020, 2030], timesteps], names=["period", "timestep"]
+    )
+    n.set_snapshots(snapshots)
+
+    # Check that _investment_periods_data is synchronized with both periods
+    assert n._investment_periods_data.index.tolist() == [2020, 2030]
+
+    # Test 2: Extending snapshots to add new period
+    n2 = pypsa.Network()
+    n2.set_snapshots(pd.date_range("2013-03-01", periods=3, freq="D"))
+    n2.add("Bus", "bus0")
+
+    # Convert to multi-period with period 0
+    snapshots_multi = pd.MultiIndex.from_product(
+        [[0], n2.snapshots], names=["period", "timestep"]
+    )
+    n2.set_snapshots(snapshots_multi)
+    n2.set_investment_periods([0])
+
+    # Check that after set_investment_periods, data is synchronized
+    assert n2._investment_periods_data.index.tolist() == [0]
+
+    # Extend snapshots to add period 2040
+    new_snapshots = pd.date_range("2013-03-01", periods=3, freq="D")
+    extended = pd.MultiIndex.from_tuples(
+        list(n2.snapshots) + [(2040, t) for t in new_snapshots],
+        names=["period", "timestep"],
+    )
+    n2.set_snapshots(extended)
+
+    # Check that _investment_periods_data is synchronized
+    assert n2._investment_periods_data.index.tolist() == [0, 2040]