Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cc7de38
Feature/398 feature facet plots in results (#419)
FBumann Oct 18, 2025
fedd6b6
Feature/398 feature facet plots in results heatmaps (#418)
FBumann Oct 18, 2025
84aa03d
Feature/398 feature facet plots in results charge state (#417)
FBumann Oct 18, 2025
51da844
Fix Error handling in plot_heatmap()
FBumann Oct 18, 2025
b94f223
Feature/398 feature facet plots in results pie (#421)
FBumann Oct 18, 2025
b2b8eb7
6. Optimized time-step check
FBumann Oct 18, 2025
c747faf
Typo
FBumann Oct 18, 2025
bf4e33d
Improve type handling
FBumann Oct 18, 2025
0c5764c
Update other tests
FBumann Oct 18, 2025
59ada64
Handle backwards compatability
FBumann Oct 18, 2025
b56ed12
Add better error messages if both new and old api are used
FBumann Oct 18, 2025
980d7de
Add old api explicitly
FBumann Oct 18, 2025
9aea60e
Add old api explicitly
FBumann Oct 18, 2025
922a95f
Improve consistency and properly deprectae the indexer parameter
FBumann Oct 18, 2025
bd88fb1
Remove amount of new tests
FBumann Oct 18, 2025
f156d3a
Remove amount of new tests
FBumann Oct 18, 2025
ab9e4a8
Fix CONTRIBUTING.md
FBumann Oct 18, 2025
a77b942
Remove old test file
FBumann Oct 18, 2025
18ba271
Add tests/test_heatmap_reshape.py
FBumann Oct 18, 2025
894533c
Add tests/test_heatmap_reshape.py
FBumann Oct 18, 2025
30ab7ec
Remove unused method
FBumann Oct 18, 2025
4763c29
- Implemented dashed line styling for "mixed" variables (variables …
FBumann Oct 18, 2025
e180e88
- Added fill parameter to module-level plot_heatmap function (line …
FBumann Oct 18, 2025
9c3c580
- Added np.random.seed(42) for reproducible test results
FBumann Oct 18, 2025
5938829
Improve Error Message if too many dims for matplotlib
FBumann Oct 18, 2025
33cd72a
Improve Error Message if too many dims for matplotlib
FBumann Oct 18, 2025
505edca
Improve Error Message if too many dims for matplotlib
FBumann Oct 18, 2025
33c4bec
Rename _apply_indexer_to_data() to _apply_selection_to_data()
FBumann Oct 19, 2025
b37dc6a
Bugfix
FBumann Oct 19, 2025
9ce25ab
Update CHANGELOG.md
FBumann Oct 19, 2025
bbad6cb
Catch edge case in with_plotly()
FBumann Oct 19, 2025
92d0590
Add strict=True
FBumann Oct 19, 2025
ae05346
Improve scenario_example.py
FBumann Oct 19, 2025
904be27
Improve scenario_example.py
FBumann Oct 19, 2025
2c8bd7f
Change logging level in essage about time reshape
FBumann Oct 19, 2025
55dfde9
Update CHANGELOG.md
FBumann Oct 19, 2025
9cef222
Merge remote-tracking branch 'origin/main' into feature/plots-with-fa…
FBumann Oct 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Thanks for your interest in contributing to FlixOpt! 🚀

2. **Install for Development**
```bash
pip install -e ".[full]"
pip install -e ".[full, dev, docs]"
```

3. **Make Changes & Submit PR**
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,21 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix


### ✨ Added
- **Faceting and animation support for plots**: All plotting methods now support `facet_by` and `animate_by` parameters for creating subplot grids and animations with multidimensional data (scenarios, periods, etc.)
- **New `select` parameter**: Added to all plotting methods for flexible data selection using single values, lists, slices, and index arrays
- **Heatmap `fill` parameter**: Added `fill` parameter to heatmap plotting methods to control how missing values are filled after reshaping ('ffill' or 'bfill')
- **Dashed line styling**: Area plots now automatically style "mixed" variables (containing both positive and negative values) with dashed lines, while only stacking purely positive or negative variables

### 💥 Breaking Changes

### ♻️ Changed
- **Selection behavior**: Changed default selection behavior in plotting methods - no longer automatically selects first value for non-time dimensions. Use `select` parameter for explicit selection
- **Improved error messages**: Enhanced error messages when using matplotlib engine with multidimensional data, providing clearer guidance on dimension requirements
- Improved `scenario_example.py`
- Improved error handling in `plot_heatmap()` method for better dimension validation

### 🗑️ Deprecated
- **`indexer` parameter**: The `indexer` parameter in all plotting methods is deprecated in favor of the new `select` parameter with enhanced functionality

### 🔥 Removed

Expand All @@ -75,6 +84,7 @@ If upgrading from v2.x, see the [Migration Guide](https://flixopt.github.io/flix
- Improve docs visually with new Material theme and enhanced styling

### 👷 Development
- Renamed `_apply_indexer_to_data()` to `_apply_selection_to_data()` for consistency with new API

### 🚧 Known Issues

Expand Down
128 changes: 98 additions & 30 deletions examples/04_Scenarios/scenario_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,80 @@
import flixopt as fx

if __name__ == '__main__':
# Create datetime array starting from '2020-01-01' for the given time period
timesteps = pd.date_range('2020-01-01', periods=9, freq='h')
# Create datetime array starting from '2020-01-01' for one week
timesteps = pd.date_range('2020-01-01', periods=24 * 7, freq='h')
scenarios = pd.Index(['Base Case', 'High Demand'])
periods = pd.Index([2020, 2021, 2022])

# --- Create Time Series Data ---
# Heat demand profile (e.g., kW) over time and corresponding power prices
heat_demand_per_h = pd.DataFrame(
{'Base Case': [30, 0, 90, 110, 110, 20, 20, 20, 20], 'High Demand': [30, 0, 100, 118, 125, 20, 20, 20, 20]},
index=timesteps,
# Realistic daily patterns: morning/evening peaks, night/midday lows
np.random.seed(42)
n_hours = len(timesteps)

# Heat demand: 24-hour patterns (kW) for Base Case and High Demand scenarios
base_daily_pattern = np.array(
[22, 20, 18, 18, 20, 25, 40, 70, 95, 110, 85, 65, 60, 58, 62, 68, 75, 88, 105, 125, 130, 122, 95, 35]
)
high_daily_pattern = np.array(
[28, 25, 22, 22, 24, 30, 52, 88, 118, 135, 105, 80, 75, 72, 75, 82, 92, 108, 128, 148, 155, 145, 115, 48]
)

# Tile and add variation
base_demand = np.tile(base_daily_pattern, n_hours // 24 + 1)[:n_hours] * (
1 + np.random.uniform(-0.05, 0.05, n_hours)
)
high_demand = np.tile(high_daily_pattern, n_hours // 24 + 1)[:n_hours] * (
1 + np.random.uniform(-0.07, 0.07, n_hours)
)

heat_demand_per_h = pd.DataFrame({'Base Case': base_demand, 'High Demand': high_demand}, index=timesteps)

# Power prices: hourly factors (night low, peak high) and period escalation (2020-2022)
hourly_price_factors = np.array(
[
0.70,
0.65,
0.62,
0.60,
0.62,
0.70,
0.95,
1.15,
1.30,
1.25,
1.10,
1.00,
0.95,
0.90,
0.88,
0.92,
1.00,
1.10,
1.25,
1.40,
1.35,
1.20,
0.95,
0.80,
]
)
power_prices = np.array([0.08, 0.09, 0.10])
period_base_prices = np.array([0.075, 0.095, 0.135]) # €/kWh for 2020, 2021, 2022

flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=np.array([0.5, 0.6]))
price_series = np.zeros((n_hours, 3))
for period_idx, base_price in enumerate(period_base_prices):
price_series[:, period_idx] = (
np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours]
* base_price
* (1 + np.random.uniform(-0.03, 0.03, n_hours))
)

power_prices = price_series.mean(axis=0)
Comment on lines +39 to +78
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix dimensional error in power price calculation.

Line 78 takes mean(axis=0), which averages across time (axis 0) and produces 3 scalar values (one per period). However, power_prices is later used in effects_per_flow_hour (line 181), which requires temporal data varying over time.

This collapses the hourly price variation into period averages, losing all time-of-day pricing information.

Apply this diff to preserve time-varying prices:

-    power_prices = price_series.mean(axis=0)
+    # power_prices shape: (n_hours, 3) for periods [2020, 2021, 2022]
+    power_prices = price_series

Or if period-specific prices are needed, construct a proper time series per period using the FlowSystem's period dimension.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Power prices: hourly factors (night low, peak high) and period escalation (2020-2022)
hourly_price_factors = np.array(
[
0.70,
0.65,
0.62,
0.60,
0.62,
0.70,
0.95,
1.15,
1.30,
1.25,
1.10,
1.00,
0.95,
0.90,
0.88,
0.92,
1.00,
1.10,
1.25,
1.40,
1.35,
1.20,
0.95,
0.80,
]
)
power_prices = np.array([0.08, 0.09, 0.10])
period_base_prices = np.array([0.075, 0.095, 0.135]) # €/kWh for 2020, 2021, 2022
flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=np.array([0.5, 0.6]))
price_series = np.zeros((n_hours, 3))
for period_idx, base_price in enumerate(period_base_prices):
price_series[:, period_idx] = (
np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours]
* base_price
* (1 + np.random.uniform(-0.03, 0.03, n_hours))
)
power_prices = price_series.mean(axis=0)
# Power prices: hourly factors (night low, peak high) and period escalation (2020-2022)
hourly_price_factors = np.array(
[
0.70,
0.65,
0.62,
0.60,
0.62,
0.70,
0.95,
1.15,
1.30,
1.25,
1.10,
1.00,
0.95,
0.90,
0.88,
0.92,
1.00,
1.10,
1.25,
1.40,
1.35,
1.20,
0.95,
0.80,
]
)
period_base_prices = np.array([0.075, 0.095, 0.135]) # €/kWh for 2020, 2021, 2022
price_series = np.zeros((n_hours, 3))
for period_idx, base_price in enumerate(period_base_prices):
price_series[:, period_idx] = (
np.tile(hourly_price_factors, n_hours // 24 + 1)[:n_hours]
* base_price
* (1 + np.random.uniform(-0.03, 0.03, n_hours))
)
# power_prices shape: (n_hours, 3) for periods [2020, 2021, 2022]
power_prices = price_series
🤖 Prompt for AI Agents
In examples/04_Scenarios/scenario_example.py around lines 39-78, the code
collapses the hourly price_series to 3 period averages by calling
price_series.mean(axis=0); this removes hourly variation required later by
effects_per_flow_hour. Preserve the time-varying series instead: keep
power_prices as the full price_series (n_hours x periods) so downstream code
receives per-hour prices, or if a single hourly vector is required choose the
appropriate period column (e.g., price_series[:, period_index]) matched to the
FlowSystem period dimension; update any downstream calls that expect the old
shape accordingly.


# Scenario weights: probability of each scenario occurring
# Base Case: 60% probability, High Demand: 40% probability
scenario_weights = np.array([0.6, 0.4])

flow_system = fx.FlowSystem(timesteps=timesteps, periods=periods, scenarios=scenarios, weights=scenario_weights)

# --- Define Energy Buses ---
# These represent nodes, where the used medias are balanced (electricity, heat, and gas)
Expand All @@ -35,22 +95,24 @@
description='Kosten',
is_standard=True, # standard effect: no explicit value needed for costs
is_objective=True, # Minimizing costs as the optimization objective
share_from_temporal={'CO2': 0.2},
share_from_temporal={'CO2': 0.2}, # Carbon price: 0.2 €/kg CO2 (e.g., carbon tax)
)

# CO2 emissions effect with an associated cost impact
# CO2 emissions effect with constraint
# Maximum of 1000 kg CO2/hour represents a regulatory or voluntary emissions limit
CO2 = fx.Effect(
label='CO2',
unit='kg',
description='CO2_e-Emissionen',
maximum_per_hour=1000, # Max CO2 emissions per hour
maximum_per_hour=1000, # Regulatory emissions limit: 1000 kg CO2/hour
)

# --- Define Flow System Components ---
# Boiler: Converts fuel (gas) into thermal energy (heat)
# Modern condensing gas boiler with realistic efficiency
boiler = fx.linear_converters.Boiler(
label='Boiler',
eta=0.5,
eta=0.92, # Realistic efficiency for modern condensing gas boiler (92%)
Q_th=fx.Flow(
label='Q_th',
bus='Fernwärme',
Expand All @@ -63,27 +125,28 @@
)

# Combined Heat and Power (CHP): Generates both electricity and heat from fuel
# Modern CHP unit with realistic efficiencies (total efficiency ~88%)
chp = fx.linear_converters.CHP(
label='CHP',
eta_th=0.5,
eta_el=0.4,
eta_th=0.48, # Realistic thermal efficiency (48%)
eta_el=0.40, # Realistic electrical efficiency (40%)
P_el=fx.Flow('P_el', bus='Strom', size=60, relative_minimum=5 / 60, on_off_parameters=fx.OnOffParameters()),
Q_th=fx.Flow('Q_th', bus='Fernwärme'),
Q_fu=fx.Flow('Q_fu', bus='Gas'),
)

# Storage: Energy storage system with charging and discharging capabilities
# Storage: Thermal energy storage system with charging and discharging capabilities
# Realistic thermal storage parameters (e.g., insulated hot water tank)
storage = fx.Storage(
label='Storage',
charging=fx.Flow('Q_th_load', bus='Fernwärme', size=1000),
discharging=fx.Flow('Q_th_unload', bus='Fernwärme', size=1000),
capacity_in_flow_hours=fx.InvestParameters(effects_of_investment=20, fixed_size=30, mandatory=True),
initial_charge_state=0, # Initial storage state: empty
relative_maximum_charge_state=np.array([80, 70, 80, 80, 80, 80, 80, 80, 80]) * 0.01,
relative_maximum_final_charge_state=0.8,
eta_charge=0.9,
eta_discharge=1, # Efficiency factors for charging/discharging
relative_loss_per_hour=0.08, # 8% loss per hour. Absolute loss depends on current charge state
relative_maximum_final_charge_state=np.array([0.8, 0.5, 0.1]),
eta_charge=0.95, # Realistic charging efficiency (~95%)
eta_discharge=0.98, # Realistic discharging efficiency (~98%)
relative_loss_per_hour=np.array([0.008, 0.015]), # Realistic thermal losses: 0.8-1.5% per hour
Comment on lines +146 to +149
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify dimensional alignment of storage parameter arrays.

The arrays have dimensions that suggest specific mappings:

  • relative_maximum_final_charge_state (3 values) likely maps to 3 periods
  • relative_loss_per_hour (2 values) likely maps to 2 scenarios

Ensure this dimensional mapping is intentional and aligns with how the FlowSystem will interpret these parameters.

Run the following script to confirm the dimensional interpretation:


🏁 Script executed:

#!/bin/bash
# Verify how Storage parameters handle array inputs and their dimensional mapping

# Check Storage class parameter documentation
ast-grep --pattern $'class Storage($$$):
  $$$
  def __init__(
    $$$,
    relative_maximum_final_charge_state$$$,
    $$$,
    relative_loss_per_hour$$$,
    $$$
  ):
    $$$'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find Storage class definition
echo "=== Searching for Storage class ==="
rg -n "class Storage" --type=py -A 20

echo -e "\n=== Searching for relative_maximum_final_charge_state in Storage context ==="
rg -n "relative_maximum_final_charge_state" --type=py -B 5 -A 2

echo -e "\n=== Searching for relative_loss_per_hour in Storage context ==="
rg -n "relative_loss_per_hour" --type=py -B 5 -A 2

Length of output: 27465


🏁 Script executed:

#!/bin/bash
# Verify periods and scenarios count in scenario_example.py
echo "=== Searching for periods definition in scenario_example.py ==="
rg -n "periods|scenarios" examples/04_Scenarios/scenario_example.py -B 2 -A 1 | head -40

echo -e "\n=== Getting FlowSystem setup context ==="
rg -n "FlowSystem" examples/04_Scenarios/scenario_example.py -A 15 | head -50

Length of output: 1603


🏁 Script executed:

#!/bin/bash
# Find fit_to_model_coords implementation to understand array handling
echo "=== Searching for fit_to_model_coords definition ==="
rg -n "def fit_to_model_coords" --type=py -A 30

echo -e "\n=== Check for any validation of array shapes ==="
rg -n "relative_loss_per_hour|relative_maximum_final_charge_state" flixopt/components.py -B 2 -A 2 | grep -A 5 "fit_to_model_coords"

Length of output: 2808


Fix array dimensions to match expected parameter shapes in Storage component.

The storage parameters have dimensional mismatches that will cause failures or unexpected behavior:

  1. relative_maximum_final_charge_state=np.array([0.8, 0.5, 0.1]) — Provided 3 values but the framework expects shape matching dims=['period', 'scenario'] (i.e., 3×2=6 values or a scalar). The 3 values align only with periods, missing the scenario dimension.

  2. relative_loss_per_hour=np.array([0.008, 0.015]) — Provided 2 values for a TemporalDataUser parameter that defaults to all model dimensions (time, period, scenario). Expected shape is 168×3×2 or scalar. The 2 values only match scenarios.

Solutions:

  • Use scalar values: relative_maximum_final_charge_state=0.8 and relative_loss_per_hour=0.008
  • Or reshape arrays to match full expected dimensions with proper broadcasting semantics
🤖 Prompt for AI Agents
In examples/04_Scenarios/scenario_example.py around lines 146 to 149, the
Storage parameters use arrays with incorrect dimensions:
relative_maximum_final_charge_state is a length-3 array (periods) but the
Storage expects dims=['period','scenario'] (3×2) or a scalar, and
relative_loss_per_hour is length-2 (scenarios) but TemporalDataUser expects full
time×period×scenario shape (168×3×2) or a scalar; fix by replacing these with
scalars (e.g., relative_maximum_final_charge_state=0.8 and
relative_loss_per_hour=0.008) or construct arrays with the full expected shape
(or broadcastable shape) matching model dimensions (time, period, scenario) so
shapes align with the Storage component requirements.

prevent_simultaneous_charge_and_discharge=True, # Prevent charging and discharging at the same time
)

Expand All @@ -94,10 +157,22 @@
)

# Gas Source: Gas tariff source with associated costs and CO2 emissions
# Realistic gas prices varying by period (reflecting 2020-2022 energy crisis)
# 2020: 0.04 €/kWh, 2021: 0.06 €/kWh, 2022: 0.11 €/kWh
gas_prices_per_period = np.array([0.04, 0.06, 0.11])

# CO2 emissions factor for natural gas: ~0.202 kg CO2/kWh (realistic value)
gas_co2_emissions = 0.202

gas_source = fx.Source(
label='Gastarif',
outputs=[
fx.Flow(label='Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={costs.label: 0.04, CO2.label: 0.3})
fx.Flow(
label='Q_Gas',
bus='Gas',
size=1000,
effects_per_flow_hour={costs.label: gas_prices_per_period, CO2.label: gas_co2_emissions},
)
],
)

Expand All @@ -124,21 +199,14 @@
calculation.results.plot_heatmap('CHP(Q_th)|flow_rate')

# --- Analyze Results ---
calculation.results['Fernwärme'].plot_node_balance_pie()
calculation.results['Fernwärme'].plot_node_balance(mode='stacked_bar')
calculation.results['Storage'].plot_node_balance()
calculation.results.plot_heatmap('CHP(Q_th)|flow_rate')
calculation.results['Storage'].plot_charge_state()
calculation.results['Fernwärme'].plot_node_balance_pie(select={'period': 2020, 'scenario': 'Base Case'})

# Convert the results for the storage component to a dataframe and display
df = calculation.results['Storage'].node_balance_with_charge_state()
print(df)

# Plot charge state using matplotlib
fig, ax = calculation.results['Storage'].plot_charge_state(engine='matplotlib')
# Customize the plot further if needed
ax.set_title('Storage Charge State Over Time')
# Or save the figure
# fig.savefig('storage_charge_state.png')

# Save results to file for later usage
calculation.results.to_file()
Loading