MESQUAL 202: KPI Collections and Pretty Tables¶
This notebook demonstrates how to extract, organize, and present KPI data in well-formatted tables with automatic unit normalization.
Introduction¶
Building on the KPI framework fundamentals from notebook 201, this notebook focuses on practical data extraction and presentation:
- DataFrame Export: Converting KPI collections to pandas DataFrames
- Unit Normalization: Automatic unit selection for readable tables
- Comparison KPIs: Creating and analyzing scenario differences
- Advanced Filtering: Model property-based filtering
- Pretty Tables: Publication-ready table formatting
- MultiIndex Support: Hierarchical table organization
These techniques enable rapid creation of analysis-ready tables from complex multi-scenario KPI collections.
Setup¶
First, we need to set up the environment. If you are on Colab, the first cell will clone and install all dependencies. You will have to restart the session afterwards and continue with cell 2. If you are in a local environment, make sure that you have followed the Getting started steps in the README, so that mesqual and all requirements are installed.
import os
if "COLAB_RELEASE_TAG" in os.environ:
import importlib.util
def is_module_available(module_name):
return importlib.util.find_spec(module_name) is not None
if os.path.exists("mesqual-vanilla-studies") and is_module_available("mesqual"):
print("✅ Environment already set up. Skipping installation.")
else:
print("🔧 Setting up Colab environment...")
!git clone --recursive https://github.com/helgeesch/mesqual-vanilla-studies.git
%cd mesqual-vanilla-studies/
!pip install git+https://github.com/helgeesch/mesqual -U
!pip install git+https://github.com/helgeesch/mesqual-pypsa -U
!pip install git+https://github.com/helgeesch/captain-arro -U
!pip install -r requirements.txt
print('✅ Setup complete. 🔁 Restart the session, then skip this cell and continue with the next one.')
else:
print("🖥️ Running locally. No setup needed.")
🖥️ Running locally. No setup needed.
import os
if "COLAB_RELEASE_TAG" in os.environ:
import sys
sys.path.append('/content/mesqual-vanilla-studies')
os.chdir('/content/mesqual-vanilla-studies')
else:
def setup_notebook_env():
"""Set working directory to repo root and ensure it's in sys.path."""
import os
import sys
from pathlib import Path
def find_repo_root(start_path: Path) -> Path:
current = start_path.resolve()
while current != current.parent:
if (current / 'vanilla').exists():
return current
current = current.parent
raise FileNotFoundError(f"Repository root not found from: {start_path}")
repo_root = find_repo_root(Path.cwd())
os.chdir(repo_root)
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
setup_notebook_env()
try:
from mesqual import StudyManager
except ImportError:
raise ImportError("❌ 'mesqual' not found. If you're running locally, make sure you've installed all dependencies as described in the README.")
if not os.path.isdir("studies"):
raise RuntimeError(f"❌ 'studies' folder not found. Make sure your working directory is set to the mesqual-vanilla-studies root. Current working directory: {os.getcwd()}")
print("✅ Environment ready. Let's go!")
✅ Environment ready. Let's go!
import pandas as pd
from mesqual import kpis
from mesqual.units import Units
from vanilla.notebook_config import configure_clean_output_for_jupyter_notebook
configure_clean_output_for_jupyter_notebook()
# Pandas display options for better tables
pd.set_option('display.max_columns', None)
pd.set_option('display.precision', 2)
pd.set_option('display.width', None)
Load Study and Generate KPIs¶
Let's load our study and create a comprehensive set of KPIs:
from studies.study_01_intro_to_mesqual.scripts.setup_study_manager import get_scigrid_de_study_manager
study = get_scigrid_de_study_manager()
# Clear any existing KPIs
study.scen.clear_kpi_collection_for_all_child_datasets()
study.comp.clear_kpi_collection_for_all_child_datasets()
print("✅ Study loaded successfully")
✅ Study loaded successfully
# Generate price KPIs for all scenarios
price_def = (
kpis.FlagAggKPIBuilder()
.for_flag('control_areas_t.vol_weighted_marginal_price')
.with_aggregation(kpis.Aggregations.Mean)
.for_all_objects()
.build()
)
study.scen.add_kpis_from_definitions_to_all_child_datasets(price_def)
print(f"✅ Generated price KPIs for {len(study.scen.datasets)} scenarios")
print(f" Total KPIs: {study.scen.get_merged_kpi_collection().size}")
✅ Generated price KPIs for 5 scenarios Total KPIs: 20
Part 1: Basic DataFrame Export¶
The simplest way to export KPIs is to convert them to a DataFrame:
# Get KPI collection and export to DataFrame
kpi_collection = study.scen.get_merged_kpi_collection()
df = kpi_collection.to_dataframe()
print(f"DataFrame shape: {df.shape}")
print(f"\nColumns: {df.columns.tolist()}")
print(f"\nFirst few rows:")
print(df.head())
DataFrame shape: (20, 18)
Columns: ['name', 'flag', 'model_flag', 'object_name', 'aggregation', 'dataset_name', 'dataset_type', 'value_comparison', 'arithmetic_operation', 'reference_dataset_name', 'variation_dataset_name', 'name_prefix', 'name_suffix', 'custom_name', 'unit', 'value', 'res_tech', 'scaling_factor']
First few rows:
name \
0 control_areas_t.vol_weighted_marginal_price Me...
1 control_areas_t.vol_weighted_marginal_price Me...
2 control_areas_t.vol_weighted_marginal_price Me...
3 control_areas_t.vol_weighted_marginal_price Me...
4 control_areas_t.vol_weighted_marginal_price Me...
flag model_flag object_name \
0 control_areas_t.vol_weighted_marginal_price control_areas 50Hertz
1 control_areas_t.vol_weighted_marginal_price control_areas Amprion
2 control_areas_t.vol_weighted_marginal_price control_areas TenneTDE
3 control_areas_t.vol_weighted_marginal_price control_areas TransnetBW
4 control_areas_t.vol_weighted_marginal_price control_areas 50Hertz
aggregation dataset_name dataset_type \
0 Mean base <class 'studies.study_01_intro_to_mesqual.scri...
1 Mean base <class 'studies.study_01_intro_to_mesqual.scri...
2 Mean base <class 'studies.study_01_intro_to_mesqual.scri...
3 Mean base <class 'studies.study_01_intro_to_mesqual.scri...
4 Mean solar_150 <class 'studies.study_01_intro_to_mesqual.scri...
value_comparison arithmetic_operation reference_dataset_name \
0 None None None
1 None None None
2 None None None
3 None None None
4 None None None
variation_dataset_name name_prefix name_suffix custom_name unit \
0 None None EUR_per_MWh
1 None None EUR_per_MWh
2 None None EUR_per_MWh
3 None None EUR_per_MWh
4 None None EUR_per_MWh
value res_tech scaling_factor
0 11.90 NaN NaN
1 20.25 NaN NaN
2 16.91 NaN NaN
3 23.62 NaN NaN
4 11.87 solar 150
Part 2: Unit Handling Strategies¶
The KPI collection supports multiple unit handling strategies:
Strategy 1: Original Units (Default)¶
df_original = kpi_collection.to_dataframe(unit_handling='original')
print("Sample values with original units:")
print(df_original[['object_name', 'dataset_name', 'value', 'unit']].head())
Sample values with original units: object_name dataset_name value unit 0 50Hertz base 11.90 EUR_per_MWh 1 Amprion base 20.25 EUR_per_MWh 2 TenneTDE base 16.91 EUR_per_MWh 3 TransnetBW base 23.62 EUR_per_MWh 4 50Hertz solar_150 11.87 EUR_per_MWh
Strategy 2: Auto-Convert to Pretty Units¶
df_pretty = kpi_collection.to_dataframe(unit_handling='auto_convert')
print("Sample values with auto-converted pretty units:")
print(df_pretty[['object_name', 'dataset_name', 'value', 'unit']].head())
Sample values with auto-converted pretty units: object_name dataset_name value unit 0 50Hertz base 11.90 EUR_per_MWh 1 Amprion base 20.25 EUR_per_MWh 2 TenneTDE base 16.91 EUR_per_MWh 3 TransnetBW base 23.62 EUR_per_MWh 4 50Hertz solar_150 11.87 EUR_per_MWh
Strategy 3: Normalize to Collection¶
Find a single "pretty" unit that works well for the entire collection:
df_normalized = kpi_collection.to_dataframe(normalize_to_collection=True)
print("All values normalized to common unit:")
print(df_normalized[['object_name', 'dataset_name', 'value', 'unit']].head())
print(f"\nAll units: {df_normalized['unit'].unique()}")
All values normalized to common unit: object_name dataset_name value unit 0 50Hertz base 11.90 EUR_per_MWh 1 Amprion base 20.25 EUR_per_MWh 2 TenneTDE base 16.91 EUR_per_MWh 3 TransnetBW base 23.62 EUR_per_MWh 4 50Hertz solar_150 11.87 EUR_per_MWh All units: ['EUR_per_MWh']
Strategy 4: Target Unit¶
Convert all KPIs to a specific unit:
df_eur_mwh = kpi_collection.to_dataframe(
unit_handling='target',
target_unit=Units.EUR_per_MWh
)
print("All values in EUR/MWh:")
print(df_eur_mwh[['object_name', 'dataset_name', 'value', 'unit']].head())
All values in EUR/MWh: object_name dataset_name value unit 0 50Hertz base 11.90 EUR_per_MWh 1 Amprion base 20.25 EUR_per_MWh 2 TenneTDE base 16.91 EUR_per_MWh 3 TransnetBW base 23.62 EUR_per_MWh 4 50Hertz solar_150 11.87 EUR_per_MWh
Part 3: Creating Pretty Tables¶
Let's create analysis-ready tables with proper organization:
Pivot Table: Scenarios × Objects¶
# Export with normalized units
df = kpi_collection.to_dataframe(normalize_to_collection=True)
# Create pivot table
pivot = df.pivot(index='object_name', columns='dataset_name', values='value')
# Get the common unit for display
common_unit = df['unit'].iloc[0]
print(f"Average Market Prices [{common_unit}]")
print("=" * 80)
print(pivot)
Average Market Prices [EUR_per_MWh] ================================================================================ dataset_name base solar_150 solar_200 wind_150 wind_200 object_name 50Hertz 11.90 11.87 11.68 6.67 4.28 Amprion 20.25 19.58 19.05 18.49 16.85 TenneTDE 16.91 16.34 15.91 14.59 13.27 TransnetBW 23.62 23.05 22.52 22.93 21.25
Styled Table with Formatting¶
# Create styled pivot table
styled = (
pivot
.style
.format("{:.2f}")
.background_gradient(cmap='RdYlGn_r', axis=None)
.set_caption(f"Average Market Prices by Control Area and Scenario [{common_unit}]")
)
styled
| dataset_name | base | solar_150 | solar_200 | wind_150 | wind_200 |
|---|---|---|---|---|---|
| object_name | |||||
| 50Hertz | 11.90 | 11.87 | 11.68 | 6.67 | 4.28 |
| Amprion | 20.25 | 19.58 | 19.05 | 18.49 | 16.85 |
| TenneTDE | 16.91 | 16.34 | 15.91 | 14.59 | 13.27 |
| TransnetBW | 23.62 | 23.05 | 22.52 | 22.93 | 21.25 |
Part 4: Comparison KPIs¶
Create and analyze scenario comparison KPIs:
# Generate comparison KPIs
comp_def = (
kpis.ComparisonKPIBuilder(price_def)
.with_comparisons([
kpis.ValueComparisons.Increase,
kpis.ValueComparisons.PercentageIncrease
])
.build()
)
study.comp.add_kpis_from_definitions_to_all_child_datasets(comp_def)
print(f"✅ Generated comparison KPIs for {len(study.comp.datasets)} comparisons")
print(f" Total comparison KPIs: {study.comp.get_merged_kpi_collection().size}")
✅ Generated comparison KPIs for 4 comparisons Total comparison KPIs: 64
Comparison Table: Absolute Differences¶
# Get absolute difference KPIs
comp_kpis = study.comp.get_merged_kpi_collection()
abs_diff_kpis = comp_kpis.filter(value_comparison=kpis.ValueComparisons.Increase)
# Export and pivot
df_comp = abs_diff_kpis.to_dataframe(normalize_to_collection=True)
pivot_comp = df_comp.pivot(index='object_name', columns='dataset_name', values='value')
comp_unit = df_comp['unit'].iloc[0]
print(f"Price Changes vs Base Scenario [{comp_unit}]")
print("=" * 80)
print(pivot_comp)
Price Changes vs Base Scenario [EUR_per_MWh] ================================================================================ dataset_name solar_150 vs base solar_200 vs base wind_150 vs base \ object_name 50Hertz -0.03 -0.23 -5.24 Amprion -0.67 -1.20 -1.76 TenneTDE -0.57 -0.99 -2.32 TransnetBW -0.57 -1.10 -0.69 dataset_name wind_200 vs base object_name 50Hertz -7.62 Amprion -3.40 TenneTDE -3.64 TransnetBW -2.36
Relative Differences (Percentage Changes)¶
# Get relative difference KPIs
rel_diff_kpis = comp_kpis.filter(value_comparison=kpis.ValueComparisons.PercentageIncrease)
# Export and pivot
df_rel = rel_diff_kpis.to_dataframe()
pivot_rel = df_rel.pivot(index='object_name', columns='dataset_name', values='value')
# Convert to percentage
pivot_rel = pivot_rel * 100
print(f"Relative Price Changes vs Base Scenario [%]")
print("=" * 80)
print(pivot_rel)
Relative Price Changes vs Base Scenario [%] ================================================================================ dataset_name solar_150 vs base solar_200 vs base wind_150 vs base \ object_name 50Hertz -25.46 -189.91 -4399.98 Amprion -332.43 -592.08 -867.61 TenneTDE -335.60 -588.05 -1371.59 TransnetBW -241.53 -463.81 -290.87 dataset_name wind_200 vs base object_name 50Hertz -6400.48 Amprion -1680.10 TenneTDE -2153.83 TransnetBW -1000.40
Part 5: Advanced Filtering¶
Use model properties to filter KPIs:
# Get KPIs for TenneTDE control area only
all_kpis = study.scen.get_merged_kpi_collection()
tennet_kpis = all_kpis.filter_by_kpi_attributes(
attributes=dict(object_name='TenneTDE')
)
print(f"KPIs for TenneTDE: {tennet_kpis.size}")
# Create table for single control area across scenarios
df_tennet = tennet_kpis.to_dataframe(normalize_to_collection=True)
print(f"\nTenneTDE Average Prices [{df_tennet['unit'].iloc[0]}]:")
print(df_tennet[['dataset_name', 'value']].set_index('dataset_name'))
KPIs for TenneTDE: 5
TenneTDE Average Prices [EUR_per_MWh]:
value
dataset_name
base 16.91
solar_150 16.34
solar_200 15.91
wind_150 14.59
wind_200 13.27
Part 6: Combined Analysis Tables¶
Combine scenarios and comparisons in unified tables:
# Get both scenario and comparison KPIs
scenario_kpis = study.scen.get_merged_kpi_collection()
comparison_kpis = study.comp.get_merged_kpi_collection().filter(
value_comparison=kpis.ValueComparisons.Increase
)
# Export both
df_scen = scenario_kpis.to_dataframe(normalize_to_collection=True)
df_comp = comparison_kpis.to_dataframe(normalize_to_collection=True)
# Add type indicator
df_scen['type'] = 'scenario'
df_comp['type'] = 'change'
# Combine
df_combined = pd.concat([df_scen, df_comp], ignore_index=True)
# Pivot for comparison
pivot_combined = df_combined.pivot(
index='object_name',
columns=['type', 'dataset_name'],
values='value'
)
# Sort columns for better readability
pivot_combined = pivot_combined.sort_index(axis=1, level=[1, 0])
print("Combined Scenario Values and Changes")
print("=" * 120)
print(pivot_combined)
Combined Scenario Values and Changes ======================================================================================================================== type scenario change scenario change \ dataset_name base solar_150 solar_150 vs base solar_200 solar_200 vs base object_name 50Hertz 11.90 11.87 -0.03 11.68 -0.23 Amprion 20.25 19.58 -0.67 19.05 -1.20 TenneTDE 16.91 16.34 -0.57 15.91 -0.99 TransnetBW 23.62 23.05 -0.57 22.52 -1.10 type scenario change scenario change dataset_name wind_150 wind_150 vs base wind_200 wind_200 vs base object_name 50Hertz 6.67 -5.24 4.28 -7.62 Amprion 18.49 -1.76 16.85 -3.40 TenneTDE 14.59 -2.32 13.27 -3.64 TransnetBW 22.93 -0.69 21.25 -2.36
Summary: KPI Collection and Table Capabilities¶
DataFrame Export Strategies:¶
Original Units (
unit_handling='original')- Keep units as computed
- Best for raw data export
Auto-Convert (
unit_handling='auto_convert')- Each KPI converted to its own pretty unit
- Good for mixed KPI types
Normalize to Collection (
normalize_to_collection=True)- Single common unit for entire collection
- Best for tables and comparisons
- Uses median order of magnitude
Target Unit (
unit_handling='target', target_unit=...)- Explicit unit specification
- Useful for standard reporting formats
Table Creation Patterns:¶
- Pivot Tables:
.pivot(index='object_name', columns='dataset_name', values='value')
Comparison Workflows:¶
- Generate scenario KPIs
- Create comparison KPIs with
kpis.ComparisonKPIBuilder - Filter by
value_comparisontype - Export to DataFrame with normalized units
- Pivot and format
Conclusion¶
The MESQUAL KPI collection system provides:
- ✅ Flexible Export - Multiple unit handling strategies
- ✅ Automatic Normalization - Smart unit selection for readable tables
- ✅ Rich Filtering - By attributes and model properties
- ✅ Easy Pivoting - Transform to analysis-ready formats
- ✅ Publication Quality - Styled tables with proper formatting
These capabilities enable rapid creation of professional analysis tables from complex multi-scenario energy system studies, with minimal code and maximum clarity.