diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a98ea85..fe0b382ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,18 +51,67 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ## [Unreleased] - ????-??-?? -**Summary**: Renaming parameters in Linear Transformers for readability & Internal architecture improvements to simplify FlowSystem-Element coupling and eliminate circular dependencies. Old parameters till work but emmit warnings. +**Summary**: Renaming parameters in Linear Transformers for readability (old parameters still work but emit warnings), new bounds for weighted sums over all periods for effects and flow hours, and refactored weights handling to fix `.sel()` issues with periods. If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). ### ✨ Added +- **Internal period weight computation**: `FlowSystem` now automatically computes `period_weights` from the period index (similar to `hours_per_timestep` for time dimension), ensuring weights are always consistent with the actual periods + +- **New constraint parameters for sum across all periods**: + - `Effect`: Added `minimum_over_periods` and `maximum_over_periods` for weighted sum constraints across all periods (complements existing per-period `minimum_total`/`maximum_total`) + - `Flow`: Added `flow_hours_max_over_periods` and `flow_hours_min_over_periods` for weighted sum constraints across all periods + + **Important**: + - Constraints with the `_over_periods` suffix compute weighted sums across all periods using the **raw** weights from period durations (or user-specified weights). These constraints always use unnormalized weights. + - The `normalize_weights` parameter (in `Calculation` or `FlowSystem.create_model()`) only affects the objective function, not the `_over_periods` constraints. + - Per-period constraints (without the suffix) apply separately to each individual period. + + **Example**: + ```python + # Per-period constraint: limits apply to EACH period individually + # With periods=[2020, 2030, 2040], this creates 3 separate constraints + effect = fx.Effect('costs', maximum_total=1000) # ≤1000 in 2020 AND ≤1000 in 2030 AND ≤1000 in 2040 + + # Over-periods constraint: limits apply to WEIGHTED SUM across ALL periods + # With periods=[2020, 2030, 2040] (auto-derived weights: [10, 10, 10] from 10-year intervals) + effect = fx.Effect('costs', maximum_over_periods=1000) # 10×costs₂₀₂₀ + 10×costs₂₀₃₀ + 10×costs₂₀₄₀ ≤ 1000 + + # Note: If normalize_weights=True, the objective uses normalized weights [0.33, 0.33, 0.33], + # but the constraint above still uses raw weights [10, 10, 10] + ``` + - **Auto-modeling**: `Calculation.solve()` now automatically calls `do_modeling()` if not already done, making the explicit `do_modeling()` call optional for simpler workflows -- **System validation**: Added `_validate_system_integrity()` to validate cross-element references (e.g., Flow.bus) immediately after transformation, providing clearer error messages -- **Element registration validation**: Added checks to prevent elements from being assigned to multiple FlowSystems simultaneously -- **Helper methods in Interface base class**: Added `_fit_coords()` and `_fit_effect_coords()` convenience wrappers for cleaner data transformation code -- **FlowSystem property in Interface**: Added `flow_system` property to access the linked FlowSystem with clear error messages if not yet linked + +### 💥 Breaking Changes +- **FlowSystem weights parameter renamed**: The `weights` parameter in `FlowSystem.__init__()` has been renamed to `scenario_weights` to clarify that it only accepts scenario dimension weights (not period × scenario) + - Period weights are now **always computed internally** from the period index (similar to `hours_per_timestep` for time) + - The combined `weights` (period × scenario) are computed automatically by multiplying `period_weights × scenario_weights` + - only scenario_weights are normalized in the objective function + + **Migration**: Update your code from: + ```python + # Old (v3.6 and earlier) + fs = FlowSystem(..., weights=np.array([0.3, 0.5, 0.2])) # scenario weights + ``` + + To: + ```python + # New (v3.7+) + fs = FlowSystem(..., scenario_weights=np.array([0.3, 0.5, 0.2])) + ``` + + **Note**: If you were previously passing period × scenario weights to `weights`, you now need to: + 1. Pass only scenario weights to `scenario_weights` + 2. Period weights will be computed automatically from your `periods` index ### ♻️ Changed +- **Period weights now computed from period index**: FlowSystem now computes `period_weights` automatically from the period index (using period intervals/differences), making weight handling consistent with `hours_per_timestep` for time dimension + - Added `_update_period_metadata()` method (analogous to `_update_time_metadata()`) to recalculate weights when periods are selected + - Period weights are stored separately in `FlowSystem.period_weights` (1D array with 'period' dimension) + - Scenario weights are stored in `FlowSystem.scenario_weights` (1D array with 'scenario' dimension) + - Combined weights `FlowSystem.weights` (2D array with 'period' and 'scenario' dimensions) are computed via `_compute_weights()` method + - **Refactored FlowSystem-Element coupling**: - Introduced `_set_flow_system()` method in Interface base class to propagate FlowSystem reference to nested Interface objects - Each Interface subclass now explicitly propagates the reference to its nested interfaces (e.g., Component → OnOffParameters, Flow → InvestParameters) @@ -83,11 +132,24 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - `Boiler`: `eta` → `thermal_efficiency` - `Power2Heat`: `eta` → `thermal_efficiency` - `CHP`: `eta_th` → `thermal_efficiency`, `eta_el` → `electrical_efficiency` - - `HetaPump`: `COP` → `cop` - - `HetaPumpWithSource`: `COP` → `cop` + - `HeatPump`: `COP` → `cop` + - `HeatPumpWithSource`: `COP` → `cop` - **Storage Parameters**: - `Storage`: `initial_charge_state="lastValueOfSim"` → `initial_charge_state="equals_last"` +- **Parameter naming consistency**: Established consistent naming pattern for constraint parameters across `Effect`, `Flow`, and `OnOffParameters`: + - Per-period constraints use no suffix or clarified names (e.g., `minimum_total`, `flow_hours_max`, `on_hours_min`) + - Sum-over-all-periods constraints use `_over_periods` suffix (e.g., `minimum_over_periods`, `flow_hours_max_over_periods`) + +- **Flow parameters** (renamed for consistency): + - Renamed `flow_hours_total_max` → `flow_hours_max` (per-period constraint) + - Renamed `flow_hours_total_min` → `flow_hours_min` (per-period constraint) + +- **OnOffParameters** (renamed for consistency): + - Renamed `on_hours_total_max` → `on_hours_max` (per-period constraint) + - Renamed `on_hours_total_min` → `on_hours_min` (per-period constraint) + - Renamed `switch_on_total_max` → `switch_on_max` (per-period constraint) + ### 🗑️ Deprecated - **Old parameter names in `linear_converters.py`**: The following parameter names are now deprecated and accessible as properties/kwargs that emit `DeprecationWarning`. They will be removed in v4.0.0: - **Flow parameters**: `Q_fu`, `Q_th`, `P_el`, `Q_ab` (use `fuel_flow`, `thermal_flow`, `electrical_flow`, `heat_source_flow` instead) @@ -95,7 +157,23 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - **COP parameter**: `COP` (use lowercase `cop` instead) - **Storage Parameter**: `Storage`: `initial_charge_state="lastValueOfSim"` (use `initial_charge_state="equals_last"`) + +- **Flow parameters**: `flow_hours_total_max`, `flow_hours_total_min` (use `flow_hours_max`, `flow_hours_min`) +- **OnOffParameters**: `on_hours_total_max`, `on_hours_total_min`, `switch_on_total_max` (use `on_hours_max`, `on_hours_min`, `switch_on_max`) + +**Migration**: Simply rename parameters by removing `_total` from the middle: +- `flow_hours_total_max` → `flow_hours_max` +- `on_hours_total_min` → `on_hours_min` +- `switch_on_total_max` → `switch_on_max` + +All deprecated parameter names continue to work with deprecation warnings for backward compatibility. **Deprecated names will be removed in version 4.0.0.** Please update your code to use the new parameter names. Additional property aliases have been added internally to handle various naming variations that may have been used. + + ### 🐛 Fixed +- **Fixed weights not recalculating when using `.sel()` on periods**: `FlowSystem.sel()` and `FlowSystem.isel()` now correctly recalculate `period_weights` and `weights` when selecting a subset of periods (previously weights would be incorrectly sliced instead of recomputed from the new period index) + - Added `_update_period_metadata()` call in `_dataset_sel()` and `_dataset_isel()` to ensure weights stay consistent with selected periods + - This matches the existing behavior for time dimension where `hours_per_timestep` is recalculated on selection + - Fixed inconsistent argument passing in `_fit_effect_coords()` - standardized all calls to use named arguments (`prefix=`, `effect_values=`, `suffix=`) instead of mix of positional and named arguments - Fixed `check_bounds` function in `linear_converters.py` to normalize array inputs before comparisons, ensuring correct boundary checks with DataFrames, Series, and other array-like types @@ -104,6 +182,10 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp - Added comprehensive docstrings to `_do_modeling()` methods explaining the pattern: "Create variables, constraints, and nested submodels" - Added missing type hints throughout the codebase - Improved code organization by making FlowSystem reference propagation explicit and traceable +- **System validation**: Added `_validate_system_integrity()` to validate cross-element references (e.g., Flow.bus) immediately after transformation, providing clearer error messages +- **Element registration validation**: Added checks to prevent elements from being assigned to multiple FlowSystems simultaneously +- **Helper methods in Interface base class**: Added `_fit_coords()` and `_fit_effect_coords()` convenience wrappers for cleaner data transformation code +- **FlowSystem property in Interface**: Added `flow_system` property to access the linked FlowSystem with clear error messages if not yet linked --- diff --git a/docs/user-guide/mathematical-notation/dimensions.md b/docs/user-guide/mathematical-notation/dimensions.md index d1bc99c8e..fc16ad0d5 100644 --- a/docs/user-guide/mathematical-notation/dimensions.md +++ b/docs/user-guide/mathematical-notation/dimensions.md @@ -52,7 +52,7 @@ flow_system = fx.FlowSystem( timesteps=timesteps, periods=periods, scenarios=scenarios, - weights=np.array([0.5, 0.5]) # Scenario weights + scenario_weights=np.array([0.5, 0.5]) # Scenario weights ) ``` @@ -220,28 +220,75 @@ Where: Weights determine the relative importance of scenarios and periods in the objective function. -**Specification:** +### Scenario Weights + +You provide scenario weights explicitly via the `scenario_weights` parameter: ```python flow_system = fx.FlowSystem( timesteps=timesteps, - periods=periods, scenarios=scenarios, - weights=weights # Shape depends on dimensions + scenario_weights=np.array([0.3, 0.7]) # Scenario probabilities ) ``` -**Weight Dimensions:** +**Default:** If not specified, all scenarios have equal weight (normalized to sum to 1). + +### Period Weights + +Period weights are **automatically computed** from the period index (similar to how `hours_per_timestep` is computed from the time index): -| Dimensions Present | Weight Shape | Example | Meaning | -|-------------------|--------------|---------|---------| -| Time + Scenario | 1D array of length `n_scenarios` | `[0.3, 0.7]` | Scenario probabilities | -| Time + Period | 1D array of length `n_periods` | `[0.5, 0.3, 0.2]` | Period importance | -| Time + Period + Scenario | 2D array `(n_periods, n_scenarios)` | `[[0.25, 0.25], [0.25, 0.25]]` | Combined weights | +```python +# Period weights are computed from the differences between period values +periods = pd.Index([2020, 2025, 2030, 2035]) +# → period_weights = [5, 5, 5, 5] (representing 5-year intervals) -**Default:** If not specified, all scenarios/periods have equal weight (normalized to sum to 1). +flow_system = fx.FlowSystem( + timesteps=timesteps, + periods=periods, + # No need to specify period weights - they're computed automatically +) +``` + +**How period weights are computed:** +- For periods `[2020, 2025, 2030, 2035]`, the weights are `[5, 5, 5, 5]` (the interval sizes) +- This ensures that when you use `.sel()` to select a subset of periods, the weights are correctly recalculated +- You can specify `weight_of_last_period` if the last period weight cannot be inferred from the index + +### Combined Weights + +When both periods and scenarios are present, the combined `weights` array (accessible via `flow_system.model.objective_weights`) is computed as: + +$$ +w_{y,s} = w_y \times \frac{w_s}{\sum_{s \in \mathcal{S}} w_s} +$$ + +Where: +- $w_y$ are the period weights (computed from period index) +- $w_s$ are the scenario weights (user-specified) +- $\mathcal{S}$ is the set of all scenarios +- The scenario weights are normalized to sum to 1 before multiplication + +**Example:** +```python +periods = pd.Index([2020, 2030, 2040]) # → period_weights = [10, 10, 10] +scenarios = pd.Index(['Base', 'High']) +scenario_weights = np.array([0.6, 0.4]) + +flow_system = fx.FlowSystem( + timesteps=timesteps, + periods=periods, + scenarios=scenarios, + scenario_weights=scenario_weights +) + +# Combined weights shape: (3 periods, 2 scenarios) +# [[6.0, 4.0], # 2020: 10 × [0.6, 0.4] +# [6.0, 4.0], # 2030: 10 × [0.6, 0.4] +# [6.0, 4.0]] # 2040: 10 × [0.6, 0.4] +``` -**Normalization:** Set `normalize_weights=True` in `Calculation` to automatically normalize weights to sum to 1. +**Normalization:** Set `normalize_weights=False` in `Calculation` to turn of the normalization. --- diff --git a/docs/user-guide/mathematical-notation/features/OnOffParameters.md b/docs/user-guide/mathematical-notation/features/OnOffParameters.md index 4ec6a9726..6bf40fec9 100644 --- a/docs/user-guide/mathematical-notation/features/OnOffParameters.md +++ b/docs/user-guide/mathematical-notation/features/OnOffParameters.md @@ -237,10 +237,10 @@ For equipment with OnOffParameters, the complete constraint system includes: **Key Parameters:** - `effects_per_switch_on`: Costs per startup event - `effects_per_running_hour`: Costs per hour of operation -- `on_hours_total_min`, `on_hours_total_max`: Total runtime bounds +- `on_hours_min`, `on_hours_max`: Total runtime bounds - `consecutive_on_hours_min`, `consecutive_on_hours_max`: Consecutive runtime bounds - `consecutive_off_hours_min`, `consecutive_off_hours_max`: Consecutive shutdown bounds -- `switch_on_total_max`: Maximum number of startups +- `switch_on_max`: Maximum number of startups - `force_switch_on`: Create switch variables even without limits (for tracking) See the [`OnOffParameters`][flixopt.interface.OnOffParameters] API documentation for complete parameter list and usage examples. @@ -265,7 +265,7 @@ power_plant = OnOffParameters( effects_per_running_hour={'fixed_om': 125}, # €125/hour while running consecutive_on_hours_min=8, # Minimum 8-hour run consecutive_off_hours_min=4, # 4-hour cooling period - on_hours_total_max=6000, # Annual limit + on_hours_max=6000, # Annual limit ) ``` @@ -276,7 +276,7 @@ batch_reactor = OnOffParameters( consecutive_on_hours_min=12, # 12-hour minimum batch consecutive_on_hours_max=24, # 24-hour maximum batch consecutive_off_hours_min=6, # Cleaning time - switch_on_total_max=200, # Max 200 batches + switch_on_max=200, # Max 200 batches ) ``` @@ -286,7 +286,7 @@ hvac = OnOffParameters( effects_per_switch_on={'compressor_wear': 0.5}, consecutive_on_hours_min=1, # Prevent short cycling consecutive_off_hours_min=0.5, # 30-min minimum off - switch_on_total_max=2000, # Limit compressor starts + switch_on_max=2000, # Limit compressor starts ) ``` @@ -296,7 +296,7 @@ backup_gen = OnOffParameters( effects_per_switch_on={'fuel_priming': 50}, # L diesel consecutive_on_hours_min=0.5, # 30-min test duration consecutive_off_hours_max=720, # Test every 30 days - on_hours_total_min=26, # Weekly testing requirement + on_hours_min=26, # Weekly testing requirement ) ``` diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 0f7ca0b82..cad938cb2 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -68,15 +68,15 @@ relative_minimum=5 / 50, # Minimum part load relative_maximum=1, # Maximum part load previous_flow_rate=50, # Previous flow rate - flow_hours_total_max=1e6, # Total energy flow limit + flow_hours_max=1e6, # Total energy flow limit on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, # Minimum operating hours - on_hours_total_max=1000, # Maximum operating hours + on_hours_min=0, # Minimum operating hours + on_hours_max=1000, # Maximum operating hours consecutive_on_hours_max=10, # Max consecutive operating hours consecutive_on_hours_min=np.array([1, 1, 1, 1, 1, 2, 2, 2, 2]), # min consecutive operation hours consecutive_off_hours_max=10, # Max consecutive off hours effects_per_switch_on=0.01, # Cost per switch-on - switch_on_total_max=1000, # Max number of starts + switch_on_max=1000, # Max number of starts ), ), fuel_flow=fx.Flow(label='Q_fu', bus='Gas', size=200), diff --git a/examples/04_Scenarios/scenario_example.py b/examples/04_Scenarios/scenario_example.py index ca50876c7..6bb920188 100644 --- a/examples/04_Scenarios/scenario_example.py +++ b/examples/04_Scenarios/scenario_example.py @@ -83,7 +83,9 @@ # 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) + flow_system = fx.FlowSystem( + timesteps=timesteps, periods=periods, scenarios=scenarios, scenario_weights=scenario_weights + ) # --- Define Energy Buses --- # These represent nodes, where the used medias are balanced (electricity, heat, and gas) diff --git a/flixopt/calculation.py b/flixopt/calculation.py index fcde018b7..2977f5a02 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -48,7 +48,7 @@ class for defined way of solving a flow_system optimization name: name of calculation flow_system: flow_system which should be calculated folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Whether to automatically normalize the weights (periods and scenarios) to sum up to 1 when solving. + normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ @@ -182,7 +182,7 @@ class FullCalculation(Calculation): name: name of calculation flow_system: flow_system which should be calculated folder: folder where results should be saved. If None, then the current working directory is used. - normalize_weights: Whether to automatically normalize the weights (periods and scenarios) to sum up to 1 when solving. + normalize_weights: Whether to automatically normalize the weights of scenarios to sum up to 1 when solving. active_timesteps: Deprecated. Use FlowSystem.sel(time=...) or FlowSystem.isel(time=...) instead. """ diff --git a/flixopt/components.py b/flixopt/components.py index 38f980126..5b0854240 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -647,7 +647,7 @@ class Transmission(Component): on_off_parameters=OnOffParameters( effects_per_switch_on={'maintenance': 0.1}, consecutive_on_hours_min=2, # Minimum 2-hour operation - switch_on_total_max=10, # Maximum 10 starts per day + switch_on_max=10, # Maximum 10 starts per day ), ) ``` diff --git a/flixopt/effects.py b/flixopt/effects.py index e011b185d..02181920a 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -16,6 +16,7 @@ import xarray as xr from loguru import logger +from .core import PlausibilityError from .features import ShareAllocationModel from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io @@ -23,7 +24,7 @@ from collections.abc import Iterator from .flow_system import FlowSystem - from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_TPS, Scalar + from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_S, Numeric_TPS, Scalar @register_class_for_io @@ -48,17 +49,28 @@ class Effect(Element): without effect dictionaries. Used for simplified effect specification (and less boilerplate code). is_objective: If True, this effect serves as the optimization objective function. Only one effect can be marked as objective per optimization. + weights: Optional custom weights for periods and scenarios (Numeric_PS). + If provided, overrides the FlowSystem's default period weights for this effect. + Useful for effect-specific weighting (e.g., discounting for costs vs equal weights for CO2). + If None, uses FlowSystem's default weights. share_from_temporal: Temporal cross-effect contributions. Maps temporal contributions from other effects to this effect. share_from_periodic: Periodic cross-effect contributions. Maps periodic contributions from other effects to this effect. - minimum_temporal: Minimum allowed total contribution across all timesteps. - maximum_temporal: Maximum allowed total contribution across all timesteps. + minimum_temporal: Minimum allowed total contribution across all timesteps (per period). + maximum_temporal: Maximum allowed total contribution across all timesteps (per period). minimum_per_hour: Minimum allowed contribution per hour. maximum_per_hour: Maximum allowed contribution per hour. - minimum_periodic: Minimum allowed total periodic contribution. - maximum_periodic: Maximum allowed total periodic contribution. - minimum_total: Minimum allowed total effect (temporal + periodic combined). + minimum_periodic: Minimum allowed total periodic contribution (per period). + maximum_periodic: Maximum allowed total periodic contribution (per period). + minimum_total: Minimum allowed total effect (temporal + periodic combined) per period. + maximum_total: Maximum allowed total effect (temporal + periodic combined) per period. + minimum_over_periods: Minimum allowed weighted sum of total effect across ALL periods. + Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. + Requires FlowSystem to have a 'period' dimension (i.e., periods must be defined). + maximum_over_periods: Maximum allowed weighted sum of total effect across ALL periods. + Weighted by effect-specific weights if defined, otherwise by FlowSystem period weights. + Requires FlowSystem to have a 'period' dimension (i.e., periods must be defined). meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -82,14 +94,25 @@ class Effect(Element): ) ``` - CO2 emissions: + CO2 emissions with per-period limit: ```python co2_effect = Effect( label='CO2', unit='kg_CO2', description='Carbon dioxide emissions', - maximum_total=1_000_000, # 1000 t CO2 annual limit + maximum_total=100_000, # 100 t CO2 per period + ) + ``` + + CO2 emissions with total limit across all periods: + + ```python + co2_effect = Effect( + label='CO2', + unit='kg_CO2', + description='Carbon dioxide emissions', + maximum_over_periods=1_000_000, # 1000 t CO2 total across all periods ) ``` @@ -100,7 +123,7 @@ class Effect(Element): label='land_usage', unit='m²', description='Land area requirement', - maximum_total=50_000, # Maximum 5 hectares available + maximum_total=50_000, # Maximum 5 hectares per period ) ``` @@ -138,7 +161,7 @@ class Effect(Element): description='Industrial water usage', minimum_per_hour=10, # Minimum 10 m³/h for process stability maximum_per_hour=500, # Maximum 500 m³/h capacity limit - maximum_total=100_000, # Annual permit limit: 100,000 m³ + maximum_over_periods=100_000, # Annual permit limit: 100,000 m³ ) ``` @@ -166,6 +189,7 @@ def __init__( meta_data: dict | None = None, is_standard: bool = False, is_objective: bool = False, + period_weights: Numeric_PS | None = None, share_from_temporal: Effect_TPS | Numeric_TPS | None = None, share_from_periodic: Effect_PS | Numeric_PS | None = None, minimum_temporal: Numeric_PS | None = None, @@ -176,6 +200,8 @@ def __init__( maximum_per_hour: Numeric_TPS | None = None, minimum_total: Numeric_PS | None = None, maximum_total: Numeric_PS | None = None, + minimum_over_periods: Numeric_S | None = None, + maximum_over_periods: Numeric_S | None = None, **kwargs, ): super().__init__(label, meta_data=meta_data) @@ -183,6 +209,7 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective + self.period_weights = period_weights # Share parameters accept Effect_* | Numeric_* unions (dict or single value). # Store as-is here; transform_data() will normalize via fit_effects_to_model_coords(). # Default to {} when None (no shares defined). @@ -204,8 +231,6 @@ def __init__( maximum_per_hour = self._handle_deprecated_kwarg( kwargs, 'maximum_operation_per_hour', 'maximum_per_hour', maximum_per_hour ) - - # Validate any remaining unexpected kwargs self._validate_kwargs(kwargs) # Set attributes directly @@ -217,6 +242,8 @@ def __init__( self.maximum_per_hour = maximum_per_hour self.minimum_total = minimum_total self.maximum_total = maximum_total + self.minimum_over_periods = minimum_over_periods + self.maximum_over_periods = maximum_over_periods # Backwards compatible properties (deprecated) @property @@ -339,6 +366,46 @@ def maximum_operation_per_hour(self, value): ) self.maximum_per_hour = value + @property + def minimum_total_per_period(self): + """DEPRECATED: Use 'minimum_total' property instead.""" + warnings.warn( + "Property 'minimum_total_per_period' is deprecated. Use 'minimum_total' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.minimum_total + + @minimum_total_per_period.setter + def minimum_total_per_period(self, value): + """DEPRECATED: Use 'minimum_total' property instead.""" + warnings.warn( + "Property 'minimum_total_per_period' is deprecated. Use 'minimum_total' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.minimum_total = value + + @property + def maximum_total_per_period(self): + """DEPRECATED: Use 'maximum_total' property instead.""" + warnings.warn( + "Property 'maximum_total_per_period' is deprecated. Use 'maximum_total' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.maximum_total + + @maximum_total_per_period.setter + def maximum_total_per_period(self, value): + """DEPRECATED: Use 'maximum_total' property instead.""" + warnings.warn( + "Property 'maximum_total_per_period' is deprecated. Use 'maximum_total' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.maximum_total = value + def transform_data(self, name_prefix: str = '') -> None: prefix = '|'.join(filter(None, [name_prefix, self.label_full])) self.minimum_per_hour = self._fit_coords(f'{prefix}|minimum_per_hour', self.minimum_per_hour) @@ -375,6 +442,15 @@ def transform_data(self, name_prefix: str = '') -> None: self.maximum_total = self._fit_coords( f'{prefix}|maximum_total', self.maximum_total, dims=['period', 'scenario'] ) + self.minimum_over_periods = self._fit_coords( + f'{prefix}|minimum_over_periods', self.minimum_over_periods, dims=['scenario'] + ) + self.maximum_over_periods = self._fit_coords( + f'{prefix}|maximum_over_periods', self.maximum_over_periods, dims=['scenario'] + ) + self.period_weights = self._fit_coords( + f'{prefix}|period_weights', self.period_weights, dims=['period', 'scenario'] + ) def create_model(self, model: FlowSystemModel) -> EffectModel: self._plausibility_checks() @@ -382,8 +458,15 @@ def create_model(self, model: FlowSystemModel) -> EffectModel: return self.submodel def _plausibility_checks(self) -> None: - # TODO: Check for plausibility - pass + # Check that minimum_over_periods and maximum_over_periods require a period dimension + if ( + self.minimum_over_periods is not None or self.maximum_over_periods is not None + ) and self.flow_system.periods is None: + raise PlausibilityError( + f"Effect '{self.label}': minimum_over_periods and maximum_over_periods require " + f"the FlowSystem to have a 'period' dimension. Please define periods when creating " + f'the FlowSystem, or remove these constraints.' + ) class EffectModel(ElementModel): @@ -392,6 +475,26 @@ class EffectModel(ElementModel): def __init__(self, model: FlowSystemModel, element: Effect): super().__init__(model, element) + @property + def period_weights(self) -> xr.DataArray: + """ + Get period weights for this effect. + + Returns effect-specific weights if defined, otherwise falls back to FlowSystem period weights. + This allows different effects to have different weighting schemes over periods (e.g., discounting for costs, + equal weights for CO2 emissions). + + Returns: + Weights with period dimensions (if applicable) + """ + effect_weights = self.element.period_weights + default_weights = self.element._flow_system.period_weights + if effect_weights is not None: # Use effect-specific weights + return effect_weights + elif default_weights is not None: # Fall back to FlowSystem weights + return default_weights + return self.element._fit_coords(name='period_weights', data=1, dims=['period']) + def _do_modeling(self): """Create variables, constraints, and nested submodels""" super()._do_modeling() @@ -434,6 +537,21 @@ def _do_modeling(self): self.total == self.temporal.total + self.periodic.total, name=self.label_full, short_name='total' ) + # Add weighted sum over all periods constraint if minimum_over_periods or maximum_over_periods is defined + if self.element.minimum_over_periods is not None or self.element.maximum_over_periods is not None: + # Calculate weighted sum over all periods + weighted_total = (self.total * self.period_weights).sum('period') + + # Create tracking variable for the weighted sum + self.total_over_periods = self.add_variables( + lower=self.element.minimum_over_periods if self.element.minimum_over_periods is not None else -np.inf, + upper=self.element.maximum_over_periods if self.element.maximum_over_periods is not None else np.inf, + coords=self._model.get_coords(['scenario']), + short_name='total_over_periods', + ) + + self.add_constraints(self.total_over_periods == weighted_total, short_name='total_over_periods') + EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares @@ -677,12 +795,10 @@ def _do_modeling(self): # Add cross-effect shares self._add_share_between_effects() - # Set objective - # Note: penalty.total is used here, but penalty shares from buses/components - # are added later via add_share_to_penalty(). The ShareAllocationModel supports - # this pattern - shares can be added after the objective is defined. + # Use objective weights with objective effect self._model.add_objective( - (self.effects.objective_effect.submodel.total * self._model.weights).sum() + self.penalty.total.sum() + (self.effects.objective_effect.submodel.total * self._model.objective_weights).sum() + + self.penalty.total.sum() ) def _add_share_between_effects(self): diff --git a/flixopt/elements.py b/flixopt/elements.py index 8f88cbfbb..29387919e 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -339,8 +339,12 @@ class Flow(Element): effects_per_flow_hour: Operational costs/impacts per flow-hour. Dict mapping effect names to values (e.g., {'cost': 45, 'CO2': 0.8}). on_off_parameters: Binary operation constraints (OnOffParameters). Default: None. - flow_hours_total_max: Maximum cumulative flow-hours. Alternative to load_factor_max. - flow_hours_total_min: Minimum cumulative flow-hours. Alternative to load_factor_min. + flow_hours_max: Maximum cumulative flow-hours per period. Alternative to load_factor_max. + flow_hours_min: Minimum cumulative flow-hours per period. Alternative to load_factor_min. + flow_hours_max_over_periods: Maximum weighted sum of flow-hours across ALL periods. + Weighted by FlowSystem period weights. + flow_hours_min_over_periods: Minimum weighted sum of flow-hours across ALL periods. + Weighted by FlowSystem period weights. fixed_relative_profile: Predetermined pattern as fraction of size. Flow rate = size × fixed_relative_profile(t). previous_flow_rate: Initial flow state for on/off dynamics. Default: None (off). @@ -386,7 +390,7 @@ class Flow(Element): effects_per_switch_on={'startup_cost': 100, 'wear': 0.1}, consecutive_on_hours_min=2, # Must run at least 2 hours consecutive_off_hours_min=1, # Must stay off at least 1 hour - switch_on_total_max=200, # Maximum 200 starts per period + switch_on_max=200, # Maximum 200 starts per period ), ) ``` @@ -417,8 +421,9 @@ class Flow(Element): ``` Design Considerations: - **Size vs Load Factors**: Use `flow_hours_total_min/max` for absolute limits, - `load_factor_min/max` for utilization-based constraints. + **Size vs Load Factors**: Use `flow_hours_min/max` for absolute limits per period, + `load_factor_min/max` for utilization-based constraints, or `flow_hours_min/max_over_periods` for + limits across all periods. **Relative Bounds**: Set `relative_minimum > 0` only when equipment cannot operate below that level. Use `on_off_parameters` for discrete on/off behavior. @@ -448,12 +453,15 @@ def __init__( relative_maximum: Numeric_TPS = 1, effects_per_flow_hour: Effect_TPS | Numeric_TPS | None = None, on_off_parameters: OnOffParameters | None = None, - flow_hours_total_max: Numeric_PS | None = None, - flow_hours_total_min: Numeric_PS | None = None, + flow_hours_max: Numeric_PS | None = None, + flow_hours_min: Numeric_PS | None = None, + flow_hours_max_over_periods: Numeric_S | None = None, + flow_hours_min_over_periods: Numeric_S | None = None, load_factor_min: Numeric_PS | None = None, load_factor_max: Numeric_PS | None = None, previous_flow_rate: Scalar | list[Scalar] | None = None, meta_data: dict | None = None, + **kwargs, ): super().__init__(label, meta_data=meta_data) self.size = CONFIG.Modeling.big if size is None else size @@ -463,10 +471,33 @@ def __init__( self.load_factor_min = load_factor_min self.load_factor_max = load_factor_max + + # Handle deprecated parameters + flow_hours_max = self._handle_deprecated_kwarg( + kwargs, 'flow_hours_per_period_max', 'flow_hours_max', flow_hours_max + ) + flow_hours_min = self._handle_deprecated_kwarg( + kwargs, 'flow_hours_per_period_min', 'flow_hours_min', flow_hours_min + ) + # Also handle the older deprecated names + flow_hours_max = self._handle_deprecated_kwarg(kwargs, 'flow_hours_total_max', 'flow_hours_max', flow_hours_max) + flow_hours_min = self._handle_deprecated_kwarg(kwargs, 'flow_hours_total_min', 'flow_hours_min', flow_hours_min) + flow_hours_max_over_periods = self._handle_deprecated_kwarg( + kwargs, 'total_flow_hours_max', 'flow_hours_max_over_periods', flow_hours_max_over_periods + ) + flow_hours_min_over_periods = self._handle_deprecated_kwarg( + kwargs, 'total_flow_hours_min', 'flow_hours_min_over_periods', flow_hours_min_over_periods + ) + + # Validate any remaining unexpected kwargs + self._validate_kwargs(kwargs) + # self.positive_gradient = TimeSeries('positive_gradient', positive_gradient, self) self.effects_per_flow_hour = effects_per_flow_hour if effects_per_flow_hour is not None else {} - self.flow_hours_total_max = flow_hours_total_max - self.flow_hours_total_min = flow_hours_total_min + self.flow_hours_max = flow_hours_max + self.flow_hours_min = flow_hours_min + self.flow_hours_max_over_periods = flow_hours_max_over_periods + self.flow_hours_min_over_periods = flow_hours_min_over_periods self.on_off_parameters = on_off_parameters self.previous_flow_rate = previous_flow_rate @@ -505,11 +536,17 @@ def transform_data(self, name_prefix: str = '') -> None: self.relative_maximum = self._fit_coords(f'{prefix}|relative_maximum', self.relative_maximum) self.fixed_relative_profile = self._fit_coords(f'{prefix}|fixed_relative_profile', self.fixed_relative_profile) self.effects_per_flow_hour = self._fit_effect_coords(prefix, self.effects_per_flow_hour, 'per_flow_hour') - self.flow_hours_total_max = self._fit_coords( - f'{prefix}|flow_hours_total_max', self.flow_hours_total_max, dims=['period', 'scenario'] + self.flow_hours_max = self._fit_coords( + f'{prefix}|flow_hours_max', self.flow_hours_max, dims=['period', 'scenario'] ) - self.flow_hours_total_min = self._fit_coords( - f'{prefix}|flow_hours_total_min', self.flow_hours_total_min, dims=['period', 'scenario'] + self.flow_hours_min = self._fit_coords( + f'{prefix}|flow_hours_min', self.flow_hours_min, dims=['period', 'scenario'] + ) + self.flow_hours_max_over_periods = self._fit_coords( + f'{prefix}|flow_hours_max_over_periods', self.flow_hours_max_over_periods, dims=['scenario'] + ) + self.flow_hours_min_over_periods = self._fit_coords( + f'{prefix}|flow_hours_min_over_periods', self.flow_hours_min_over_periods, dims=['scenario'] ) self.load_factor_max = self._fit_coords( f'{prefix}|load_factor_max', self.load_factor_max, dims=['period', 'scenario'] @@ -573,6 +610,47 @@ def size_is_fixed(self) -> bool: # Wenn kein InvestParameters existiert --> True; Wenn Investparameter, den Wert davon nehmen return False if (isinstance(self.size, InvestParameters) and self.size.fixed_size is None) else True + # Backwards compatible properties (deprecated) + @property + def flow_hours_total_max(self): + """DEPRECATED: Use 'flow_hours_max' property instead.""" + warnings.warn( + "Property 'flow_hours_total_max' is deprecated. Use 'flow_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_max + + @flow_hours_total_max.setter + def flow_hours_total_max(self, value): + """DEPRECATED: Use 'flow_hours_max' property instead.""" + warnings.warn( + "Property 'flow_hours_total_max' is deprecated. Use 'flow_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_max = value + + @property + def flow_hours_total_min(self): + """DEPRECATED: Use 'flow_hours_min' property instead.""" + warnings.warn( + "Property 'flow_hours_total_min' is deprecated. Use 'flow_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.flow_hours_min + + @flow_hours_total_min.setter + def flow_hours_total_min(self, value): + """DEPRECATED: Use 'flow_hours_min' property instead.""" + warnings.warn( + "Property 'flow_hours_total_min' is deprecated. Use 'flow_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.flow_hours_min = value + def _format_invest_params(self, params: InvestParameters) -> str: """Format InvestParameters for display.""" return f'size: {params.format_for_repr()}' @@ -598,19 +676,49 @@ def _do_modeling(self): self._constraint_flow_rate() - # Total flow hours tracking (creates variable + constraint) + # Total flow hours tracking (per period) ModelingPrimitives.expression_tracking_variable( model=self, name=f'{self.label_full}|total_flow_hours', tracked_expression=(self.flow_rate * self._model.hours_per_step).sum('time'), bounds=( - self.element.flow_hours_total_min if self.element.flow_hours_total_min is not None else 0, - self.element.flow_hours_total_max if self.element.flow_hours_total_max is not None else None, + self.element.flow_hours_min if self.element.flow_hours_min is not None else 0, + self.element.flow_hours_max if self.element.flow_hours_max is not None else None, ), coords=['period', 'scenario'], short_name='total_flow_hours', ) + # Weighted sum over all periods constraint + if self.element.flow_hours_min_over_periods is not None or self.element.flow_hours_max_over_periods is not None: + # Validate that period dimension exists + if self._model.flow_system.periods is None: + raise ValueError( + f"{self.label_full}: flow_hours_*_over_periods requires FlowSystem to define 'periods', " + f'but FlowSystem has no period dimension. Please define periods in FlowSystem constructor.' + ) + # Get period weights from FlowSystem + weighted_flow_hours_over_periods = (self.total_flow_hours * self._model.flow_system.period_weights).sum( + 'period' + ) + + # Create tracking variable for the weighted sum + ModelingPrimitives.expression_tracking_variable( + model=self, + name=f'{self.label_full}|flow_hours_over_periods', + tracked_expression=weighted_flow_hours_over_periods, + bounds=( + self.element.flow_hours_min_over_periods + if self.element.flow_hours_min_over_periods is not None + else 0, + self.element.flow_hours_max_over_periods + if self.element.flow_hours_max_over_periods is not None + else None, + ), + coords=['scenario'], + short_name='flow_hours_over_periods', + ) + # Load factor constraints self._create_bounds_for_load_factor() diff --git a/flixopt/features.py b/flixopt/features.py index 0ec935db6..8c4bf7c70 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -186,8 +186,8 @@ def _do_modeling(self): self, tracked_expression=(self.on * self._model.hours_per_step).sum('time'), bounds=( - self.parameters.on_hours_total_min if self.parameters.on_hours_total_min is not None else 0, - self.parameters.on_hours_total_max if self.parameters.on_hours_total_max is not None else np.inf, + self.parameters.on_hours_min if self.parameters.on_hours_min is not None else 0, + self.parameters.on_hours_max if self.parameters.on_hours_max is not None else np.inf, ), # TODO: self._model.hours_per_step.sum('time').item() + self._get_previous_on_duration()) short_name='on_hours_total', coords=['period', 'scenario'], @@ -208,10 +208,10 @@ def _do_modeling(self): coord='time', ) - if self.parameters.switch_on_total_max is not None: + if self.parameters.switch_on_max is not None: count = self.add_variables( lower=0, - upper=self.parameters.switch_on_total_max, + upper=self.parameters.switch_on_max, coords=self._model.get_coords(('period', 'scenario')), short_name='switch|count', ) diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 12970a23b..70a6b5aab 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -32,7 +32,7 @@ import pyvis - from .types import Bool_TPS, Effect_TPS, Numeric_PS, Numeric_TPS, NumericOrBool + from .types import Bool_TPS, Effect_TPS, Numeric_PS, Numeric_S, Numeric_TPS, NumericOrBool class FlowSystem(Interface, CompositeContainerMixin[Element]): @@ -51,8 +51,11 @@ class FlowSystem(Interface, CompositeContainerMixin[Element]): hours_of_previous_timesteps: Duration of previous timesteps. If None, computed from the first time interval. Can be a scalar (all previous timesteps have same duration) or array (different durations). Used to calculate previous values (e.g., consecutive_on_hours). - weights: The weights of each period and scenario. If None, all scenarios have the same weight (normalized to 1). - Its recommended to normalize the weights to sum up to 1. + weight_of_last_period: Weight/duration of the last period. If None, computed from the last period interval. + Used for calculating sums over periods in multi-period models. + scenario_weights: The weights of each scenario. If None, all scenarios have the same weight (normalized to 1). + Period weights are always computed internally from the period index (like hours_per_timestep for time). + The final `weights` array (accessible via `flow_system.model.objective_weights`) is computed as period_weights × normalized_scenario_weights, with normalization applied to the scenario weights by default. scenario_independent_sizes: Controls whether investment sizes are equalized across scenarios. - True: All sizes are shared/equalized across scenarios - False: All sizes are optimized separately per scenario @@ -157,10 +160,22 @@ def __init__( scenarios: pd.Index | None = None, hours_of_last_timestep: int | float | None = None, hours_of_previous_timesteps: int | float | np.ndarray | None = None, - weights: Numeric_PS | None = None, + weight_of_last_period: int | float | None = None, + scenario_weights: Numeric_S | None = None, scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, + **kwargs, ): + scenario_weights = self._handle_deprecated_kwarg( + kwargs, + 'weights', + 'scenario_weights', + scenario_weights, + check_conflict=False, + additional_warning_message='This might lead to later errors if your custom weights used the period dimension.', + ) + self._validate_kwargs(kwargs) + self.timesteps = self._validate_timesteps(timesteps) # Compute all time-related metadata using shared helper @@ -174,10 +189,17 @@ def __init__( self.periods = None if periods is None else self._validate_periods(periods) self.scenarios = None if scenarios is None else self._validate_scenarios(scenarios) - self.weights = weights - self.hours_per_timestep = self.fit_to_model_coords('hours_per_timestep', hours_per_timestep) + self.scenario_weights = scenario_weights # Use setter + + # Compute all period-related metadata using shared helper + (self.periods_extra, self.weight_of_last_period, weight_per_period) = self._compute_period_metadata( + self.periods, weight_of_last_period + ) + + self.period_weights: xr.DataArray | None = weight_per_period + # Element collections self.components: ElementContainer[Component] = ElementContainer( element_type_name='components', truncate_repr=10 @@ -278,6 +300,43 @@ def _calculate_hours_of_previous_timesteps( first_interval = timesteps[1] - timesteps[0] return first_interval.total_seconds() / 3600 # Convert to hours + @staticmethod + def _create_periods_with_extra(periods: pd.Index, weight_of_last_period: int | float | None) -> pd.Index: + """Create periods with an extra period at the end. + + Args: + periods: The period index (must be monotonically increasing integers) + weight_of_last_period: Weight of the last period. If None, computed from the period index. + + Returns: + Period index with an extra period appended at the end + """ + if weight_of_last_period is None: + if len(periods) < 2: + raise ValueError( + 'FlowSystem: weight_of_last_period must be provided explicitly when only one period is defined.' + ) + # Calculate weight from difference between last two periods + weight_of_last_period = int(periods[-1]) - int(periods[-2]) + + # Create the extra period value + last_period_value = int(periods[-1]) + weight_of_last_period + periods_extra = periods.append(pd.Index([last_period_value], name='period')) + return periods_extra + + @staticmethod + def calculate_weight_per_period(periods_extra: pd.Index) -> xr.DataArray: + """Calculate weight of each period from period index differences. + + Args: + periods_extra: Period index with an extra period at the end + + Returns: + DataArray with weights for each period (1D, 'period' dimension) + """ + weights = np.diff(periods_extra.to_numpy().astype(int)) + return xr.DataArray(weights, coords={'period': periods_extra[:-1]}, dims='period', name='weight_per_period') + @classmethod def _compute_time_metadata( cls, @@ -315,6 +374,39 @@ def _compute_time_metadata( return timesteps_extra, hours_of_last_timestep, hours_of_previous_timesteps, hours_per_timestep + @classmethod + def _compute_period_metadata( + cls, periods: pd.Index | None, weight_of_last_period: int | float | None = None + ) -> tuple[pd.Index | None, int | float | None, xr.DataArray | None]: + """ + Compute all period-related metadata from periods. + + This is the single source of truth for period metadata computation, used by both + __init__ and dataset operations to ensure consistency. + + Args: + periods: The period index to compute metadata from (or None if no periods) + weight_of_last_period: Weight of the last period. If None, computed from the period index. + + Returns: + Tuple of (periods_extra, weight_of_last_period, weight_per_period) + All return None if periods is None + """ + if periods is None: + return None, None, None + + # Create periods with extra period at the end + periods_extra = cls._create_periods_with_extra(periods, weight_of_last_period) + + # Calculate weight per period + weight_per_period = cls.calculate_weight_per_period(periods_extra) + + # Extract weight_of_last_period if not provided + if weight_of_last_period is None: + weight_of_last_period = weight_per_period.isel(period=-1).item() + + return periods_extra, weight_of_last_period, weight_per_period + @classmethod def _update_time_metadata( cls, @@ -359,6 +451,45 @@ def _update_time_metadata( return dataset + @classmethod + def _update_period_metadata( + cls, + dataset: xr.Dataset, + weight_of_last_period: int | float | None = None, + ) -> xr.Dataset: + """ + Update period-related attributes and data variables in dataset based on its period index. + + Recomputes weight_of_last_period, period_weights, and weights from the dataset's + period index when weight_of_last_period is None. This ensures period metadata stays + synchronized with the actual periods after operations like selection. + + This is analogous to _update_time_metadata() for time-related metadata. + + Args: + dataset: Dataset to update (will be modified in place) + weight_of_last_period: Weight of the last period. If None, computed from the period index. + + Returns: + The same dataset with updated period-related attributes and data variables + """ + new_period_index = dataset.indexes.get('period') + if new_period_index is not None and len(new_period_index) >= 1: + # Use shared helper to compute all period metadata + _, weight_of_last_period, period_weights = cls._compute_period_metadata( + new_period_index, weight_of_last_period + ) + + # Update period_weights DataArray if it exists in the dataset + if 'period_weights' in dataset.data_vars: + dataset['period_weights'] = period_weights + + # Update period-related attributes only when new values are provided/computed + if weight_of_last_period is not None: + dataset.attrs['weight_of_last_period'] = weight_of_last_period + + return dataset + def _create_reference_structure(self) -> tuple[dict, dict[str, xr.DataArray]]: """ Override Interface method to handle FlowSystem-specific serialization. @@ -436,11 +567,12 @@ def from_dataset(cls, ds: xr.Dataset) -> FlowSystem: timesteps=ds.indexes['time'], periods=ds.indexes.get('period'), scenarios=ds.indexes.get('scenario'), - weights=cls._resolve_dataarray_reference(reference_structure['weights'], arrays_dict) - if 'weights' in reference_structure - else None, hours_of_last_timestep=reference_structure.get('hours_of_last_timestep'), hours_of_previous_timesteps=reference_structure.get('hours_of_previous_timesteps'), + weight_of_last_period=reference_structure.get('weight_of_last_period'), + scenario_weights=cls._resolve_dataarray_reference(reference_structure['scenario_weights'], arrays_dict) + if 'scenario_weights' in reference_structure + else None, scenario_independent_sizes=reference_structure.get('scenario_independent_sizes', True), scenario_independent_flow_rates=reference_structure.get('scenario_independent_flow_rates', False), ) @@ -589,8 +721,6 @@ def connect_and_transform(self): logger.debug('FlowSystem already connected and transformed') return - self.weights = self.fit_to_model_coords('weights', self.weights, dims=['period', 'scenario']) - self._connect_network() for element in chain(self.components.values(), self.effects.values(), self.buses.values()): element.transform_data() @@ -990,6 +1120,50 @@ def coords(self) -> dict[FlowSystemDimensions, pd.Index]: def used_in_calculation(self) -> bool: return self._used_in_calculation + @property + def scenario_weights(self) -> xr.DataArray | None: + """ + Weights for each scenario. + + Returns: + xr.DataArray: Scenario weights with 'scenario' dimension + """ + return self._scenario_weights + + @scenario_weights.setter + def scenario_weights(self, value: Numeric_S) -> None: + """ + Set scenario weights. + + Args: + value: Scenario weights to set (will be converted to DataArray with 'scenario' dimension) + """ + self._scenario_weights = self.fit_to_model_coords('scenario_weights', value, dims=['scenario']) + + @property + def weights(self) -> Numeric_S | None: + warnings.warn( + 'FlowSystem.weights is deprecated. Use FlowSystem.scenario_weights instead.', + DeprecationWarning, + stacklevel=2, + ) + return self.scenario_weights + + @weights.setter + def weights(self, value: Numeric_S) -> None: + """ + Set weights (deprecated - sets scenario_weights). + + Args: + value: Scenario weights to set + """ + warnings.warn( + 'Setting FlowSystem.weights is deprecated. Set FlowSystem.scenario_weights instead.', + DeprecationWarning, + stacklevel=2, + ) + self.scenario_weights = value # Use the scenario_weights setter + def _validate_scenario_parameter(self, value: bool | list[str], param_name: str, element_type: str) -> None: """ Validate scenario parameter value. @@ -1116,6 +1290,11 @@ def _dataset_sel( if 'time' in indexers: result = cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + # Update period-related attributes if period was selected + # This recalculates period_weights and weights from the new period index + if 'period' in indexers: + result = cls._update_period_metadata(result) + return result def sel( @@ -1192,6 +1371,11 @@ def _dataset_isel( if 'time' in indexers: result = cls._update_time_metadata(result, hours_of_last_timestep, hours_of_previous_timesteps) + # Update period-related attributes if period was selected + # This recalculates period_weights and weights from the new period index + if 'period' in indexers: + result = cls._update_period_metadata(result) + return result def isel( diff --git a/flixopt/interface.py b/flixopt/interface.py index 89e94fa55..e3f736f9d 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -1133,10 +1133,10 @@ class OnOffParameters(Interface): effects_per_running_hour: Ongoing costs or impacts while equipment operates in the on state. Includes fuel costs, labor, consumables, or emissions. Dictionary mapping effect names to hourly values (e.g., {'fuel_cost': 45}). - on_hours_total_min: Minimum total operating hours across the entire time horizon. + on_hours_min: Minimum total operating hours per period. Ensures equipment meets minimum utilization requirements or contractual obligations (e.g., power purchase agreements, maintenance schedules). - on_hours_total_max: Maximum total operating hours across the entire time horizon. + on_hours_max: Maximum total operating hours per period. Limits equipment usage due to maintenance schedules, fuel availability, environmental permits, or equipment lifetime constraints. consecutive_on_hours_min: Minimum continuous operating duration once started. @@ -1152,11 +1152,11 @@ class OnOffParameters(Interface): consecutive_off_hours_max: Maximum continuous shutdown duration before mandatory restart. Models equipment preservation, process stability, or contractual requirements for minimum activity levels. - switch_on_total_max: Maximum number of startup operations across the time horizon. + switch_on_max: Maximum number of startup operations per period. Limits equipment cycling to reduce wear, maintenance costs, or comply with operational constraints (e.g., grid stability requirements). force_switch_on: When True, creates switch-on variables even without explicit - switch_on_total_max constraint. Useful for tracking or reporting startup + switch_on_max constraint. Useful for tracking or reporting startup events without enforcing limits. Note: @@ -1182,7 +1182,7 @@ class OnOffParameters(Interface): }, consecutive_on_hours_min=8, # Minimum 8-hour run once started consecutive_off_hours_min=4, # Minimum 4-hour cooling period - on_hours_total_max=6000, # Annual operating limit + on_hours_max=6000, # Annual operating limit ) ``` @@ -1203,8 +1203,8 @@ class OnOffParameters(Interface): consecutive_on_hours_min=12, # Minimum batch size (12 hours) consecutive_on_hours_max=24, # Maximum batch size (24 hours) consecutive_off_hours_min=6, # Cleaning and setup time - switch_on_total_max=200, # Maximum 200 batches per period - on_hours_total_max=4000, # Maximum production time + switch_on_max=200, # Maximum 200 batches per period + on_hours_max=4000, # Maximum production time ) ``` @@ -1222,9 +1222,9 @@ class OnOffParameters(Interface): }, consecutive_on_hours_min=1, # Minimum 1-hour run to avoid cycling consecutive_off_hours_min=0.5, # 30-minute minimum off time - switch_on_total_max=2000, # Limit cycling for compressor life - on_hours_total_min=2000, # Minimum operation for humidity control - on_hours_total_max=5000, # Maximum operation for energy budget + switch_on_max=2000, # Limit cycling for compressor life + on_hours_min=2000, # Minimum operation for humidity control + on_hours_max=5000, # Maximum operation for energy budget ) ``` @@ -1244,9 +1244,9 @@ class OnOffParameters(Interface): }, consecutive_on_hours_min=0.5, # Minimum test duration (30 min) consecutive_off_hours_max=720, # Maximum 30 days between tests - switch_on_total_max=52, # Weekly testing limit - on_hours_total_min=26, # Minimum annual testing (0.5h × 52) - on_hours_total_max=200, # Maximum runtime (emergencies + tests) + switch_on_max=52, # Weekly testing limit + on_hours_min=26, # Minimum annual testing (0.5h × 52) + on_hours_max=200, # Maximum runtime (emergencies + tests) ) ``` @@ -1266,7 +1266,7 @@ class OnOffParameters(Interface): consecutive_on_hours_min=1, # Minimum discharge duration consecutive_on_hours_max=4, # Maximum continuous discharge consecutive_off_hours_min=1, # Minimum rest between cycles - switch_on_total_max=365, # Daily cycling limit + switch_on_max=365, # Daily cycling limit force_switch_on=True, # Track all cycling events ) ``` @@ -1285,24 +1285,31 @@ def __init__( self, effects_per_switch_on: Effect_TPS | Numeric_TPS | None = None, effects_per_running_hour: Effect_TPS | Numeric_TPS | None = None, - on_hours_total_min: Numeric_PS | None = None, - on_hours_total_max: Numeric_PS | None = None, + on_hours_min: Numeric_PS | None = None, + on_hours_max: Numeric_PS | None = None, consecutive_on_hours_min: Numeric_TPS | None = None, consecutive_on_hours_max: Numeric_TPS | None = None, consecutive_off_hours_min: Numeric_TPS | None = None, consecutive_off_hours_max: Numeric_TPS | None = None, - switch_on_total_max: Numeric_PS | None = None, + switch_on_max: Numeric_PS | None = None, force_switch_on: bool = False, + **kwargs, ): + # Handle deprecated parameters + on_hours_min = self._handle_deprecated_kwarg(kwargs, 'on_hours_total_min', 'on_hours_min', on_hours_min) + on_hours_max = self._handle_deprecated_kwarg(kwargs, 'on_hours_total_max', 'on_hours_max', on_hours_max) + switch_on_max = self._handle_deprecated_kwarg(kwargs, 'switch_on_total_max', 'switch_on_max', switch_on_max) + self._validate_kwargs(kwargs) + self.effects_per_switch_on = effects_per_switch_on if effects_per_switch_on is not None else {} self.effects_per_running_hour = effects_per_running_hour if effects_per_running_hour is not None else {} - self.on_hours_total_min = on_hours_total_min - self.on_hours_total_max = on_hours_total_max + self.on_hours_min = on_hours_min + self.on_hours_max = on_hours_max self.consecutive_on_hours_min = consecutive_on_hours_min self.consecutive_on_hours_max = consecutive_on_hours_max self.consecutive_off_hours_min = consecutive_off_hours_min self.consecutive_off_hours_max = consecutive_off_hours_max - self.switch_on_total_max = switch_on_total_max + self.switch_on_max = switch_on_max self.force_switch_on: bool = force_switch_on def transform_data(self, name_prefix: str = '') -> None: @@ -1328,14 +1335,14 @@ def transform_data(self, name_prefix: str = '') -> None: self.consecutive_off_hours_max = self._fit_coords( f'{name_prefix}|consecutive_off_hours_max', self.consecutive_off_hours_max ) - self.on_hours_total_max = self._fit_coords( - f'{name_prefix}|on_hours_total_max', self.on_hours_total_max, dims=['period', 'scenario'] + self.on_hours_max = self._fit_coords( + f'{name_prefix}|on_hours_max', self.on_hours_max, dims=['period', 'scenario'] ) - self.on_hours_total_min = self._fit_coords( - f'{name_prefix}|on_hours_total_min', self.on_hours_total_min, dims=['period', 'scenario'] + self.on_hours_min = self._fit_coords( + f'{name_prefix}|on_hours_min', self.on_hours_min, dims=['period', 'scenario'] ) - self.switch_on_total_max = self._fit_coords( - f'{name_prefix}|switch_on_total_max', self.switch_on_total_max, dims=['period', 'scenario'] + self.switch_on_max = self._fit_coords( + f'{name_prefix}|switch_on_max', self.switch_on_max, dims=['period', 'scenario'] ) @property @@ -1363,6 +1370,79 @@ def use_switch_on(self) -> bool: self._has_value(param) for param in [ self.effects_per_switch_on, - self.switch_on_total_max, + self.switch_on_max, ] ) + + # Backwards compatible properties (deprecated) + @property + def on_hours_total_min(self): + """DEPRECATED: Use 'on_hours_min' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_min' is deprecated. Use 'on_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.on_hours_min + + @on_hours_total_min.setter + def on_hours_total_min(self, value): + """DEPRECATED: Use 'on_hours_min' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_min' is deprecated. Use 'on_hours_min' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.on_hours_min = value + + @property + def on_hours_total_max(self): + """DEPRECATED: Use 'on_hours_max' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_max' is deprecated. Use 'on_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.on_hours_max + + @on_hours_total_max.setter + def on_hours_total_max(self, value): + """DEPRECATED: Use 'on_hours_max' property instead.""" + import warnings + + warnings.warn( + "Property 'on_hours_total_max' is deprecated. Use 'on_hours_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.on_hours_max = value + + @property + def switch_on_total_max(self): + """DEPRECATED: Use 'switch_on_max' property instead.""" + import warnings + + warnings.warn( + "Property 'switch_on_total_max' is deprecated. Use 'switch_on_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.switch_on_max + + @switch_on_total_max.setter + def switch_on_total_max(self, value): + """DEPRECATED: Use 'switch_on_max' property instead.""" + import warnings + + warnings.warn( + "Property 'switch_on_total_max' is deprecated. Use 'switch_on_max' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.switch_on_max = value diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 055425eae..19b5f6d83 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -688,7 +688,7 @@ class CHP(LinearConverter): on_off_parameters=OnOffParameters( consecutive_on_hours_min=8, # Minimum 8-hour operation effects_per_switch_on={'startup_cost': 5000}, - on_hours_total_max=6000, # Annual operating limit + on_hours_max=6000, # Annual operating limit ), ) ``` diff --git a/flixopt/plotting.py b/flixopt/plotting.py index 27dbaf78c..93f4dfc85 100644 --- a/flixopt/plotting.py +++ b/flixopt/plotting.py @@ -1267,7 +1267,7 @@ def heatmap_with_plotly( Automatic time reshaping (when only time dimension remains): ```python - # Data with dims ['time', 'scenario', 'period'] + # Data with dims ['time', 'period','scenario'] # After faceting and animation, only 'time' remains -> auto-reshapes to (timestep, timeframe) fig = heatmap_with_plotly(data_array, facet_by='scenario', animate_by='period') ``` diff --git a/flixopt/results.py b/flixopt/results.py index eaff79fe4..bc80ccf98 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -1231,7 +1231,7 @@ def plot_node_balance( facet_by: Dimension(s) to create facets (subplots) for. Can be a single dimension name (str) or list of dimensions. Each unique value combination creates a subplot. Ignored if not found. Example: 'scenario' creates one subplot per scenario. - Example: ['scenario', 'period'] creates a grid of subplots for each scenario-period combination. + Example: ['period', 'scenario'] creates a grid of subplots for each scenario-period combination. animate_by: Dimension to animate over (Plotly only). Creates animation frames that cycle through dimension values. Only one dimension can be animated. Ignored if not found. facet_cols: Number of columns in the facet grid layout (default: 3). diff --git a/flixopt/structure.py b/flixopt/structure.py index 21383913b..1953a2ae4 100644 --- a/flixopt/structure.py +++ b/flixopt/structure.py @@ -186,6 +186,42 @@ def hours_per_step(self): def hours_of_previous_timesteps(self): return self.flow_system.hours_of_previous_timesteps + @property + def scenario_weights(self) -> xr.DataArray: + """ + Scenario weights of model. With optional normalization. + """ + if self.flow_system.scenarios is None: + return xr.DataArray(1) + + if self.flow_system.scenario_weights is None: + scenario_weights = xr.DataArray( + np.ones(self.flow_system.scenarios.size, dtype=float), + coords={'scenario': self.flow_system.scenarios}, + dims=['scenario'], + name='scenario_weights', + ) + else: + scenario_weights = self.flow_system.scenario_weights + + if not self.normalize_weights: + return scenario_weights + + norm = scenario_weights.sum('scenario') + if np.isclose(norm, 0.0).any(): + raise ValueError('FlowSystemModel.scenario_weights: weights sum to 0; cannot normalize.') + return scenario_weights / norm + + @property + def objective_weights(self) -> xr.DataArray: + """ + Objective weights of model. With optional normalization of scenario weights. + """ + period_weights = self.flow_system.effects.objective_effect.submodel.period_weights + scenario_weights = self.scenario_weights + + return period_weights * scenario_weights + def get_coords( self, dims: Collection[str] | None = None, @@ -217,19 +253,6 @@ def get_coords( return xr.Coordinates(coords) if coords else None - @property - def weights(self) -> int | xr.DataArray: - """Returns the weights of the FlowSystem. Normalizes to 1 if normalize_weights is True""" - if self.flow_system.weights is not None: - weights = self.flow_system.weights - else: - weights = self.flow_system.fit_to_model_coords('weights', 1, dims=['period', 'scenario']) - - if not self.normalize_weights: - return weights - - return weights / weights.sum() - def __repr__(self) -> str: """ Return a string representation of the FlowSystemModel, borrowed from linopy.Model. @@ -497,6 +520,7 @@ def _handle_deprecated_kwarg( current_value: Any = None, transform: callable = None, check_conflict: bool = True, + additional_warning_message: str = '', ) -> Any: """ Handle a deprecated keyword argument by issuing a warning and returning the appropriate value. @@ -512,6 +536,7 @@ def _handle_deprecated_kwarg( check_conflict: Whether to check if both old and new parameters are specified (default: True). Note: For parameters with non-None default values (e.g., bool parameters with default=False), set check_conflict=False since we cannot distinguish between an explicit value and the default. + additional_warning_message: Add a custom message which gets appended with a line break to the default warning. Returns: The value to use (either from old parameter or current_value) @@ -532,10 +557,13 @@ def _handle_deprecated_kwarg( """ import warnings + if additional_warning_message: + additional_warning_message = r'\n ' + additional_warning_message + old_value = kwargs.pop(old_name, None) if old_value is not None: warnings.warn( - f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead.', + f'The use of the "{old_name}" argument is deprecated. Use the "{new_name}" argument instead.{additional_warning_message}', DeprecationWarning, stacklevel=3, # Stack: this method -> __init__ -> caller ) diff --git a/tests/conftest.py b/tests/conftest.py index 1873bab0e..93d3c9f0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -165,15 +165,15 @@ def complex(): effects_of_investment_per_size={'costs': 10, 'PE': 2}, ), on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, + on_hours_min=0, + on_hours_max=1000, consecutive_on_hours_max=10, consecutive_on_hours_min=1, consecutive_off_hours_max=10, effects_per_switch_on=0.01, - switch_on_total_max=1000, + switch_on_max=1000, ), - flow_hours_total_max=1e6, + flow_hours_max=1e6, ), fuel_flow=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) @@ -451,7 +451,7 @@ def simple_flow_system_scenarios() -> fx.FlowSystem: # Create flow system flow_system = fx.FlowSystem( - base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), weights=np.array([0.5, 0.25, 0.25]) + base_timesteps, scenarios=pd.Index(['A', 'B', 'C']), scenario_weights=np.array([0.5, 0.25, 0.25]) ) flow_system.add_elements(*Buses.defaults()) flow_system.add_elements(storage, costs, co2, boiler, heat_load, gas_tariff, electricity_feed_in, chp) diff --git a/tests/test_flow.py b/tests/test_flow.py index 8a011939f..3017b25dd 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -48,8 +48,8 @@ def test_flow(self, basic_flow_system_linopy_coords, coords_config): size=100, relative_minimum=np.linspace(0, 0.5, timesteps.size), relative_maximum=np.linspace(0.5, 1, timesteps.size), - flow_hours_total_max=1000, - flow_hours_total_min=10, + flow_hours_max=1000, + flow_hours_min=10, load_factor_min=0.1, load_factor_max=0.9, ) @@ -976,7 +976,7 @@ def test_switch_on_constraints(self, basic_flow_system_linopy_coords, coords_con bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters( - switch_on_total_max=5, # Maximum 5 startups + switch_on_max=5, # Maximum 5 startups effects_per_switch_on={'costs': 100}, # 100 EUR startup cost ), ) @@ -1038,8 +1038,8 @@ def test_on_hours_limits(self, basic_flow_system_linopy_coords, coords_config): bus='Fernwärme', size=100, on_off_parameters=fx.OnOffParameters( - on_hours_total_min=20, # Minimum 20 hours of operation - on_hours_total_max=100, # Maximum 100 hours of operation + on_hours_min=20, # Minimum 20 hours of operation + on_hours_max=100, # Maximum 100 hours of operation ), ) diff --git a/tests/test_functional.py b/tests/test_functional.py index 9b3c3e6d4..98f118526 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -490,7 +490,7 @@ def test_on_total_max(solver_fixture, time_steps_fixture): 'Q_th', bus='Fernwärme', size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=1), + on_off_parameters=fx.OnOffParameters(on_hours_max=1), ), ), fx.linear_converters.Boiler( @@ -540,7 +540,7 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): 'Q_th', bus='Fernwärme', size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_max=2), + on_off_parameters=fx.OnOffParameters(on_hours_max=2), ), ), fx.linear_converters.Boiler( @@ -551,7 +551,7 @@ def test_on_total_bounds(solver_fixture, time_steps_fixture): 'Q_th', bus='Fernwärme', size=100, - on_off_parameters=fx.OnOffParameters(on_hours_total_min=3), + on_off_parameters=fx.OnOffParameters(on_hours_min=3), ), ), ) diff --git a/tests/test_linear_converter.py b/tests/test_linear_converter.py index 1884c8d72..02aa792f3 100644 --- a/tests/test_linear_converter.py +++ b/tests/test_linear_converter.py @@ -143,9 +143,7 @@ def test_linear_converter_with_on_off(self, basic_flow_system_linopy_coords, coo output_flow = fx.Flow('output', bus='output_bus', size=100) # Create OnOffParameters - on_off_params = fx.OnOffParameters( - on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'costs': 5} - ) + on_off_params = fx.OnOffParameters(on_hours_min=10, on_hours_max=40, effects_per_running_hour={'costs': 5}) # Create a linear converter with OnOffParameters converter = fx.LinearConverter( @@ -396,9 +394,7 @@ def test_piecewise_conversion_with_onoff(self, basic_flow_system_linopy_coords, ) # Create OnOffParameters - on_off_params = fx.OnOffParameters( - on_hours_total_min=10, on_hours_total_max=40, effects_per_running_hour={'costs': 5} - ) + on_off_params = fx.OnOffParameters(on_hours_min=10, on_hours_max=40, effects_per_running_hour={'costs': 5}) # Create a linear converter with piecewise conversion and on/off parameters converter = fx.LinearConverter( diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index f63405641..bdc0bdc33 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,6 +1,7 @@ import numpy as np import pandas as pd import pytest +import xarray as xr from linopy.testing import assert_linequal import flixopt as fx @@ -21,13 +22,13 @@ def test_system(): scenarios = pd.Index(['Scenario A', 'Scenario B'], name='scenario') # Create scenario weights - weights = np.array([0.7, 0.3]) + scenario_weights = np.array([0.7, 0.3]) # Create a flow system with scenarios flow_system = FlowSystem( timesteps=timesteps, scenarios=scenarios, - weights=weights, # Use TimeSeriesData for weights + scenario_weights=scenario_weights, ) # Create demand profiles that differ between scenarios @@ -156,15 +157,15 @@ def flow_system_complex_scenarios() -> fx.FlowSystem: effects_of_investment_per_size={'costs': 10, 'PE': 2}, ), on_off_parameters=fx.OnOffParameters( - on_hours_total_min=0, - on_hours_total_max=1000, + on_hours_min=0, + on_hours_max=1000, consecutive_on_hours_max=10, consecutive_on_hours_min=1, consecutive_off_hours_max=10, effects_per_switch_on=0.01, - switch_on_total_max=1000, + switch_on_max=1000, ), - flow_hours_total_max=1e6, + flow_hours_max=1e6, ), fuel_flow=fx.Flow('Q_fu', bus='Gas', size=200, relative_minimum=0, relative_maximum=1), ) @@ -238,28 +239,41 @@ def flow_system_piecewise_conversion_scenarios(flow_system_complex_scenarios) -> def test_weights(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) - flow_system_piecewise_conversion_scenarios.weights = weights - model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - normalized_weights = ( - flow_system_piecewise_conversion_scenarios.weights / flow_system_piecewise_conversion_scenarios.weights.sum() + scenario_weights = np.linspace(0.5, 1, len(scenarios)) + scenario_weights_da = xr.DataArray( + scenario_weights, + dims=['scenario'], + coords={'scenario': scenarios}, ) - np.testing.assert_allclose(model.weights.values, normalized_weights) + flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights_da + model = create_linopy_model(flow_system_piecewise_conversion_scenarios) + normalized_weights = scenario_weights / sum(scenario_weights) + np.testing.assert_allclose(model.objective_weights.values, normalized_weights) assert_linequal( model.objective.expression, (model.variables['costs'] * normalized_weights).sum() + model.variables['Penalty'] ) - assert np.isclose(model.weights.sum().item(), 1) + assert np.isclose(model.objective_weights.sum().item(), 1) def test_weights_io(flow_system_piecewise_conversion_scenarios): """Test that scenario weights are correctly used in the model.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_piecewise_conversion_scenarios.weights = weights + scenario_weights = np.linspace(0.5, 1, len(scenarios)) + scenario_weights_da = xr.DataArray( + scenario_weights, + dims=['scenario'], + coords={'scenario': scenarios}, + ) + normalized_scenario_weights_da = scenario_weights_da / scenario_weights_da.sum() + flow_system_piecewise_conversion_scenarios.scenario_weights = scenario_weights_da + model = create_linopy_model(flow_system_piecewise_conversion_scenarios) - np.testing.assert_allclose(model.weights.values, weights) - assert_linequal(model.objective.expression, (model.variables['costs'] * weights).sum() + model.variables['Penalty']) - assert np.isclose(model.weights.sum().item(), 1.0) + np.testing.assert_allclose(model.objective_weights.values, normalized_scenario_weights_da) + assert_linequal( + model.objective.expression, + (model.variables['costs'] * normalized_scenario_weights_da).sum() + model.variables['Penalty'], + ) + assert np.isclose(model.objective_weights.sum().item(), 1.0) def test_scenario_dimensions_in_variables(flow_system_piecewise_conversion_scenarios): @@ -291,7 +305,7 @@ def test_full_scenario_optimization(flow_system_piecewise_conversion_scenarios): @pytest.mark.skip(reason='This test is taking too long with highs and is too big for gurobipy free') -def test_io_persistance(flow_system_piecewise_conversion_scenarios): +def test_io_persistence(flow_system_piecewise_conversion_scenarios): """Test a full optimization with scenarios and verify results.""" scenarios = flow_system_piecewise_conversion_scenarios.scenarios weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) @@ -317,8 +331,8 @@ def test_io_persistance(flow_system_piecewise_conversion_scenarios): def test_scenarios_selection(flow_system_piecewise_conversion_scenarios): flow_system_full = flow_system_piecewise_conversion_scenarios scenarios = flow_system_full.scenarios - weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) - flow_system_full.weights = weights + scenario_weights = np.linspace(0.5, 1, len(scenarios)) / np.sum(np.linspace(0.5, 1, len(scenarios))) + flow_system_full.scenario_weights = scenario_weights flow_system = flow_system_full.sel(scenario=scenarios[0:2]) assert flow_system.scenarios.equals(flow_system_full.scenarios[0:2]) @@ -690,3 +704,82 @@ def test_scenario_parameters_io_with_calculation(): finally: # Clean up shutil.rmtree(temp_dir) + + +def test_weights_io_persistence(): + """Test that weights persist through IO operations (to_dataset/from_dataset).""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') + custom_scenario_weights = np.array([0.3, 0.5, 0.2]) + + # Create FlowSystem with custom scenario weights + fs_original = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + scenario_weights=custom_scenario_weights, + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=fx.InvestParameters( + minimum_size=10, maximum_size=100, effects_of_investment_per_size={'cost': 100} + ), + ) + ], + ) + + fs_original.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + # Save to dataset + fs_original.connect_and_transform() + ds = fs_original.to_dataset() + + # Load from dataset + fs_loaded = fx.FlowSystem.from_dataset(ds) + + # Verify weights persisted correctly + np.testing.assert_allclose(fs_loaded.weights.values, fs_original.weights.values) + assert fs_loaded.weights.dims == fs_original.weights.dims + + +def test_weights_selection(): + """Test that weights are correctly sliced when using FlowSystem.sel().""" + timesteps = pd.date_range('2023-01-01', periods=24, freq='h') + scenarios = pd.Index(['base', 'mid', 'high'], name='scenario') + custom_scenario_weights = np.array([0.3, 0.5, 0.2]) + + # Create FlowSystem with custom scenario weights + fs_full = fx.FlowSystem( + timesteps=timesteps, + scenarios=scenarios, + scenario_weights=custom_scenario_weights, + ) + + bus = fx.Bus('grid') + source = fx.Source( + label='solar', + outputs=[ + fx.Flow( + label='out', + bus='grid', + size=10, + ) + ], + ) + + fs_full.add_elements(bus, source, fx.Effect('cost', 'Total cost', '€', is_objective=True)) + + # Select a subset of scenarios + fs_subset = fs_full.sel(scenario=['base', 'high']) + + # Verify weights are correctly sliced + assert fs_subset.scenarios.equals(pd.Index(['base', 'high'], name='scenario')) + np.testing.assert_allclose(fs_subset.weights.values, custom_scenario_weights[[0, 2]]) + + # Verify weights are 1D with just scenario dimension (no period dimension) + assert fs_subset.weights.dims == ('scenario',)