Skip to content

feat(cba): add MSV extraction for seasonal storage dispatch#441

Open
cdgaete wants to merge 19 commits intomasterfrom
feat/376-cba-msv-seasonal-stores
Open

feat(cba): add MSV extraction for seasonal storage dispatch#441
cdgaete wants to merge 19 commits intomasterfrom
feat/376-cba-msv-seasonal-stores

Conversation

@cdgaete
Copy link
Member

@cdgaete cdgaete commented Feb 4, 2026

Closes #376 (Phase 1: Implementation for Stores)

Changes proposed in this Pull Request

This PR implements Marginal Storage Value (MSV) extraction for CBA seasonal storage dispatch. MSV addresses the rolling horizon myopia problem where the optimizer, seeing only a limited time window, cannot properly value seasonal storage for future scarcity periods.

New scripts:

  • scripts/cba/prepare_cba_base.py: Prepares the base CBA network with fixed capacities, hurdle costs, and removes co2_sequestration_limit
  • scripts/cba/solve_cba_msv_extraction.py: Solves perfect foresight optimization and extracts MSV from bus marginal prices
  • scripts/cba/solve_cba_network.py: Applies MSV as marginal costs and runs rolling horizon dispatch

Modified files:

  • rules/cba.smk: New rules for MSV extraction workflow
  • scripts/cba/prepare_reference.py: Integration with MSV workflow
  • scripts/cba/simplify_sb_network.py: Refactored constraint handling and cyclicity logic
  • config/config.tyndp.yaml: New MSV configuration options

Workflow

stores

New config options:

cba:
  storage:
    seasonal_carriers: [H2 Store, gas, co2 sequestered]
  msv_extraction:
    resolution: false  # or "24H", "48H" for faster solve
    resample_method: ffill

Notes

  • This implements Phase 1 (Stores) from [SUB] Transfer global constraints to weekly rolling horizon #376. Phase 2 (Storage Units) and Phase 3 (Generators) are planned follow-ups.
  • MSV is extracted from bus marginal prices at seasonal store locations after perfect foresight optimization.
  • Seasonal stores have cyclic constraints disabled; short-term storage (battery, home battery) remains cyclic.
  • co2_sequestration_limit is removed in prepare_cba_base.py so both MSV extraction and rolling horizon see the same constraint structure. unsustainable biomass limit is removed later in simplify_sb_network.py - for full consistency this could also be moved to prepare_cba_base.py in a follow-up.

Checklist

  • I tested my contribution locally and it works as intended.
  • Code and workflow changes are sufficiently documented.
  • Changed dependencies are added to pixi.toml (using pixi add ).
  • Changes in configuration options are added in config/config.default.yaml.
  • Changes in configuration options are documented in doc/configtables/*.csv.
  • Changes in configuration options are added in config/test/*.yaml.
  • Open-TYNDP SPDX license header added to all touched files.
  • For new data sources or versions, these instructions have been followed.
  • New rules are documented in the appropriate doc/*.rst files.
  • A release note doc/release_notes.rst is added.
  • Major features are documented with up-to-date information in README and doc/index.rst.
  • Module docstrings added to new Python scripts.

Carlos Gaete and others added 4 commits February 3, 2026 00:14
Implement Marginal Storage Value (MSV) extraction to guide seasonal
storage dispatch in rolling horizon CBA optimization.

Changes:
- Add prepare_cba_base rule to fix capacities and remove co2_sequestration_limit
- Add solve_cba_msv_extraction rule for perfect foresight MSV extraction
- Apply MSV as marginal_cost to seasonal stores (H2, gas, co2 sequestered)
- Support temporal resampling with configurable resolution and method (ffill/interpolate)
- Refactor pipeline: base → reference → MSV extraction → simplify → dispatch

Config options:
- cba.storage.seasonal_carriers: carriers receiving MSV
- cba.msv_extraction.resolution: extraction resolution (false or 24H, etc.)
- cba.msv_extraction.resample_method: ffill or interpolate
@cdgaete cdgaete requested a review from lisazeyen February 4, 2026 04:07
@cdgaete cdgaete self-assigned this Feb 4, 2026
@cdgaete cdgaete added CBA Cost Benefit Analysis high priority labels Feb 4, 2026
@cdgaete cdgaete added this to the Release v0.5 milestone Feb 4, 2026
@cdgaete cdgaete marked this pull request as ready for review February 4, 2026 04:09
Copy link
Collaborator

@lisazeyen lisazeyen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @cdgaete for the PR! I have a few comments concerning the workflow structure and naming.

Before running the solve_cba_msv_extraction we should

  • keep all global constraints
  • fix all capacities first but then set the primary fuel generators to extendable
    Then run the solving with the full year optional with a lower temporal resolution
    In the next rule, set the shadow prices as marginal cost, remove all global constraints, set non cyclic for seasonal storage and then prepare pint/toot run the solving.

I have some more detailed comments below, feel free to write me a message if something is unclear.

rules/cba.smk Outdated

# Simplify reference network for rolling horizon dispatch
# Disables cyclicity for seasonal stores (they will receive MSV instead)
rule simplify_sb_network:
Copy link
Collaborator

@lisazeyen lisazeyen Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the naming of the rules is now confusing, since you have as an input a CBA network but it is called simplify SB network. Simplifications of the SB network (e.g. merging the two H2 zones) should already happen in the rule which you renamed to prepare_cba_base.
I would suggest to rename rule + script and where ever else used the rule which is called in this implementation simplify_sb_network to prepare_rolling_horizon
In this rule, we

  1. add the MSV to the network
  2. set the storage non-cyclic if seasonal
  3. drop all global constraints

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 198 to 199
tyndp_conventional_carriers = snakemake.params.tyndp_conventional_carriers
n = extend_primary_fuel_sources(n, tyndp_conventional_carriers)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should already happening before solve_cba_msv_extraction you can move fixing the optimal capacities and extending primary fuel source capacities to prepare_cba_base

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

rules/cba.smk Outdated
rule simplify_sb_network:
# Prepare base network for CBA (common for both MSV extraction and reference)
# Fixes optimal capacities and adds hurdle costs
rule prepare_cba_base:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep the old name for the rule and the script here so replace here and in the following prepare_cba_base which simplify_sb_network
Because this is exactly the script which reads in the SB network and does modifications to it
What it should do here:

  • hurdle costs
  • fix optimal capacities
  • make primary fuel carriers extendable
  • keep ALL global constraints
    In the future:
  • modifications concerning EV demand/merging H2 zones

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

rules/cba.smk Outdated
Comment on lines 184 to 185
seasonal_carriers=config_provider("cba", "storage", "seasonal_carriers"),
msv_resample_method=config_provider("cba", "msv_extraction", "resample_method"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you add the MSV already in currently called simplify_sb_network (my suggestion: prepare_rolling_horizon) you can remove the params here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

cyclic_carriers = snakemake.params.get("cyclic_carriers", [])
disable_store_cyclicity(n, cyclic_carriers=cyclic_carriers)
seasonal_carriers = snakemake.params.get("seasonal_carriers", [])
disable_store_cyclicity(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add here the setting of the MSV as marginal costs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thank you very much Lisa for all the comments. Now the code looks much more clean. I will provide a detailed comment that summarize all changes.

cdgaete and others added 7 commits February 4, 2026 11:43
Co-authored-by: lisazeyen <35347358+lisazeyen@users.noreply.github.com>
Co-authored-by: lisazeyen <35347358+lisazeyen@users.noreply.github.com>
Co-authored-by: lisazeyen <35347358+lisazeyen@users.noreply.github.com>
Co-authored-by: lisazeyen <35347358+lisazeyen@users.noreply.github.com>
Co-authored-by: lisazeyen <35347358+lisazeyen@users.noreply.github.com>
…egation

Address review feedback on workflow structure and naming:

- Rename prepare_cba_base -> simplify_sb_network (fixes capacities, hurdle costs,
  extends fuel sources, disables volume limits)
- Create prepare_rolling_horizon rule (disables cyclicity, applies MSV)
- Move MSV application from solve-time to network preparation phase
- Add build_msv_snapshot_weightings rule for temporal aggregation support

The set_temporal_aggregation function requires a CSV file with pre-computed
snapshot weightings, so a new rule generates this file when hourly resolution
is configured (e.g., "24H", "48H"). This follows existing snakemake patterns.

Additional changes:
- Add seasonal_carriers and msv_extraction to config schema
- Add _CbaMsvExtractionConfig to validation config
- Improve docstrings and comments for clarity
- Fix typo: "unustainable" -> "unsustainable"
@cdgaete cdgaete force-pushed the feat/376-cba-msv-seasonal-stores branch from f4d4478 to a63723f Compare February 5, 2026 01:51
@cdgaete
Copy link
Member Author

cdgaete commented Feb 5, 2026

Thanks Lisa for the thorough review! I've addressed all your feedback:

Workflow restructuring:

  • Kept simplify_sb_network name for the initial SB network preparation (fixes capacities, hurdle costs, extends primary fuel sources, disables volume limits)
  • Created new prepare_rolling_horizon rule that handles cyclicity disabling and MSV application
  • MSV is now applied during network preparation rather than at solve time

Naming consistency:

  • simplify_sb_network → prepares base CBA network from SB
  • prepare_reference → placeholder for adding missing TOOT projects (currently passes through unchanged)
  • prepare_rolling_horizon → applies MSV + disables seasonal cyclicity

Schema updates:

  • Added seasonal_carriers and msv_extraction settings to config.schema.json
  • Added corresponding validation classes in cba.py

📝 Note on set_temporal_aggregation: Using this function required additional implementation since it expects a file path to a CSV with pre-computed snapshot weightings (not a DataFrame). I added a new rule build_msv_snapshot_weightings that generates this file when hourly resolution is configured (e.g., "48H"). This follows the existing pattern used by time_aggregation rule in the sector workflow.

Tested with resolution: "48H" - MSV extraction now runs with 4 snapshots instead of 28, then resamples back to the target resolution for rolling horizon dispatch.

Copy link
Collaborator

@lisazeyen lisazeyen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cdgaete thanks a lot for the implementations.

I have two more comments concerning the temporal aggregation and a remaining global constraint in the simplify_sb_network rule.

I also made some further changes (add MSV to storage units, removed some of the logging because it was quite extensive).

Can you check my changes, implement the remaining change concerning the temporal aggregation and do some test runs?

"cba/msv_snapshot_weightings_{planning_horizons}.csv"
),
script:
"../scripts/cba/build_msv_snapshot_weightings.py"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to reuse the existing script time_aggregation.py

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @lisazeyen, thanks for the review and commits. time_aggregation.py can't be reused as-is since it's coupled to SB workflow configs (resolution_elec/resolution_sector dict, drop_leap_day, solver_name, optional heat/solar inputs for segmentation). Refactoring it into importable functions would be the clean solution but seems out of scope for this PR.

Instead, build_msv_snapshot_weightings.py now would replicates the relevant logic from time_aggregation.py: per-year resampling, zero-weight filtering, and leap day handling for the false "Nsn" "Nh" formats. The aggregation itself reuses set_temporal_aggregation from prepare_sector_network.py. Did I understand well the task?

# build_msv_snapshot_weightings.py

# No aggregation or representative snapshots — output empty CSV
    # (set_temporal_aggregation handles "Nsn" directly without a file)
    if not resolution or (isinstance(resolution, str) and "sn" in resolution.lower()):
        logger.info("No hourly resampling needed, creating empty weightings file")
        pd.DataFrame().to_csv(snakemake.output.snapshot_weightings)

    # Hourly resampling (e.g., "24H", "48H")
    elif isinstance(resolution, str) and "h" in resolution.lower():
        offset = resolution.lower()
        logger.info(f"Resampling snapshot weightings every {offset}")

        # Resample years separately to handle non-contiguous years
        years = pd.DatetimeIndex(n.snapshots).year.unique()
        snapshot_weightings = []
        for year in years:
            sws_year = n.snapshot_weightings[n.snapshots.year == year]
            sws_year = sws_year.resample(offset).sum()
            snapshot_weightings.append(sws_year)
        snapshot_weightings = pd.concat(snapshot_weightings)

        # Drop rows with zero weight (gaps in original snapshots)
        zeros_i = snapshot_weightings.query("objective == 0").index
        snapshot_weightings.drop(zeros_i, inplace=True)

        # Handle leap days: redistribute weights to March 1st
        swi = snapshot_weightings.index
        leap_days = swi[(swi.month == 2) & (swi.day == 29)]
        if drop_leap_day and not leap_days.empty:
            for year in leap_days.year.unique():
                year_leap_days = leap_days[leap_days.year == year]
                leap_weights = snapshot_weightings.loc[year_leap_days].sum()
                march_first = pd.Timestamp(year, 3, 1, 0, 0, 0)
                snapshot_weightings.loc[march_first] = leap_weights
            snapshot_weightings = snapshot_weightings.drop(leap_days).sort_index()
            logger.info("Dropped leap day(s), redistributed weights to March 1st")

        logger.info(
            f"Generated weightings: {len(n.snapshots)} -> {len(snapshot_weightings)} snapshots"
        )
        snapshot_weightings.to_csv(snakemake.output.snapshot_weightings)

    else:
        raise ValueError(
            f"Unsupported MSV extraction resolution: {resolution!r}. "
            "Use false, 'Nsn' (e.g., '2sn'), or 'Nh' (e.g., '24H', '48H')."
        )

Disable volume limits (e_sum_min) for generators and links.

Volume limits constrain minimum energy production over the optimization period.
Disable minimum energy production limits (e_sum_min) for generators and links.
Copy link
Collaborator

@lisazeyen lisazeyen Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should not happen here but in prepare_rolling_horizon I moved it in this commit

@lisazeyen
Copy link
Collaborator

@cdgaete the last commit is not quite what I meant concerning the temporal aggregation. Can we just reuse the same script time_aggregation.py in the rule build_msv_snapshot_weightings with a different defined input/output instead of writing a new script?

@tgilon tgilon mentioned this pull request Feb 9, 2026
39 tasks
@lisazeyen lisazeyen removed this from the Release v0.5 milestone Feb 9, 2026
Split storage carriers into three categories for rolling horizon dispatch:
- seasonal_carriers: MSV + initial state from perfect foresight (hydro, H2, gas)
- accumulator_carriers: MSV only, start empty (co2 sequestered)
- cyclic_carriers: unchanged (batteries)

Add set_initial_state_from_pf() to initialize seasonal stores from PF e(t=-1),
addressing hydro reservoir under-dispatch in rolling horizon.

Extend MSV extraction and application to StorageUnit components (needed for
hydro modeled as StorageUnit in PyPSA).

Separate MSV extraction solving config (load_shedding disabled to preserve
duals) from rolling horizon solving config.

Add fallback solver mechanism for rolling horizon window failures.
Fix state_of_charge_set on hydro-reservoir StorageUnits to the PF
trajectory, giving the RH optimizer SOC guidance while retaining
dispatch freedom for all other components.

Results (2030 reference network):
- Load shedding: 12.2 TWh → 0.0001 TWh (eliminated)
- Bus prices match PF: mean 71.9 vs 71.4 EUR/MWh, no VOLL hours
- Reservoir spill: 140 TWh → 0.25 TWh (water no longer wasted)
- Generation mix within 3% of PF across all carriers

Also documents failed Approach 1 (p_dispatch_set) which caused
infeasibility because spill remained free, draining reservoirs
between RH windows.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CBA Cost Benefit Analysis high priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SUB] Transfer global constraints to weekly rolling horizon

2 participants