diff --git a/CHANGELOG.md b/CHANGELOG.md index 42869fcd6..bd11fb442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,30 +51,48 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp ## [Unreleased] - ????-??-?? -**Summary**: +**Summary**: Type system overhaul with comprehensive type hints for better IDE support and code clarity. 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 +- **New type system** (`flixopt/types.py`): + - Introduced dimension-aware type aliases using suffix notation (`_TPS`, `_PS`, `_S`) to clearly indicate which dimensions data can have + - Added `Numeric_TPS`, `Numeric_PS`, `Numeric_S` for numeric data with Time/Period/Scenario dimensions + - Added `Bool_TPS`, `Bool_PS`, `Bool_S` for boolean data with dimension support + - Added `Effect_TPS`, `Effect_PS`, `Effect_S` for effect dictionaries with dimension support + - Added `Scalar` type for scalar-only numeric values + - Added `NumericOrBool` utility type for internal use + - Type system supports scalars, numpy arrays, pandas Series/DataFrames, and xarray DataArrays ### 💥 Breaking Changes ### ♻️ Changed - **Code structure**: Removed `commons.py` module and moved all imports directly to `__init__.py` for cleaner code organization (no public API changes) +- **Type handling improvements**: Updated internal data handling to work seamlessly with the new type system ### 🗑️ Deprecated ### 🔥 Removed ### 🐛 Fixed +- Fixed `ShareAllocationModel` inconsistency where None/inf conversion happened in `__init__` instead of during modeling, which could cause issues with parameter validation +- Fixed numerous type hint inconsistencies across the codebase ### 🔒 Security ### 📦 Dependencies +- Updated `mkdocs-material` to v9.6.23 ### 📝 Docs +- Enhanced documentation in `flixopt/types.py` with comprehensive examples and dimension explanation table +- Clarified Effect type docstrings - Effect types are dicts, but single numeric values work through union types +- Added clarifying comments in `effects.py` explaining parameter handling and transformation +- Improved OnOffParameters attribute documentation + ### 👷 Development +- Added test for FlowSystem resampling ### 🚧 Known Issues diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 5de2c8870..1125da401 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -26,7 +26,7 @@ from .aggregation import Aggregation, AggregationModel, AggregationParameters from .components import Storage from .config import CONFIG -from .core import DataConverter, Scalar, TimeSeriesData, drop_constant_arrays +from .core import DataConverter, TimeSeriesData, drop_constant_arrays from .features import InvestmentModel from .flow_system import FlowSystem from .results import CalculationResults, SegmentedCalculationResults @@ -103,7 +103,7 @@ def __init__( self._modeled = False @property - def main_results(self) -> dict[str, Scalar | dict]: + def main_results(self) -> dict[str, int | float | dict]: from flixopt.features import InvestmentModel main_results = { diff --git a/flixopt/components.py b/flixopt/components.py index e4209c8ac..6a5abfc4e 100644 --- a/flixopt/components.py +++ b/flixopt/components.py @@ -12,7 +12,7 @@ import xarray as xr from . import io as fx_io -from .core import PeriodicDataUser, PlausibilityError, TemporalData, TemporalDataUser +from .core import PlausibilityError from .elements import Component, ComponentModel, Flow from .features import InvestmentModel, PiecewiseModel from .interface import InvestParameters, OnOffParameters, PiecewiseConversion @@ -23,6 +23,7 @@ import linopy from .flow_system import FlowSystem + from .types import Numeric_PS, Numeric_TPS logger = logging.getLogger('flixopt') @@ -169,7 +170,7 @@ def __init__( inputs: list[Flow], outputs: list[Flow], on_off_parameters: OnOffParameters | None = None, - conversion_factors: list[dict[str, TemporalDataUser]] | None = None, + conversion_factors: list[dict[str, Numeric_TPS]] | None = None, piecewise_conversion: PiecewiseConversion | None = None, meta_data: dict | None = None, ): @@ -386,17 +387,17 @@ def __init__( label: str, charging: Flow, discharging: Flow, - capacity_in_flow_hours: PeriodicDataUser | InvestParameters, - relative_minimum_charge_state: TemporalDataUser = 0, - relative_maximum_charge_state: TemporalDataUser = 1, - initial_charge_state: PeriodicDataUser | Literal['lastValueOfSim'] = 0, - minimal_final_charge_state: PeriodicDataUser | None = None, - maximal_final_charge_state: PeriodicDataUser | None = None, - relative_minimum_final_charge_state: PeriodicDataUser | None = None, - relative_maximum_final_charge_state: PeriodicDataUser | None = None, - eta_charge: TemporalDataUser = 1, - eta_discharge: TemporalDataUser = 1, - relative_loss_per_hour: TemporalDataUser = 0, + capacity_in_flow_hours: Numeric_PS | InvestParameters, + relative_minimum_charge_state: Numeric_TPS = 0, + relative_maximum_charge_state: Numeric_TPS = 1, + initial_charge_state: Numeric_PS | Literal['lastValueOfSim'] = 0, + minimal_final_charge_state: Numeric_PS | None = None, + maximal_final_charge_state: Numeric_PS | None = None, + relative_minimum_final_charge_state: Numeric_PS | None = None, + relative_maximum_final_charge_state: Numeric_PS | None = None, + eta_charge: Numeric_TPS = 1, + eta_discharge: Numeric_TPS = 1, + relative_loss_per_hour: Numeric_TPS = 0, prevent_simultaneous_charge_and_discharge: bool = True, balanced: bool = False, meta_data: dict | None = None, @@ -413,8 +414,8 @@ def __init__( self.charging = charging self.discharging = discharging self.capacity_in_flow_hours = capacity_in_flow_hours - self.relative_minimum_charge_state: TemporalDataUser = relative_minimum_charge_state - self.relative_maximum_charge_state: TemporalDataUser = relative_maximum_charge_state + self.relative_minimum_charge_state: Numeric_TPS = relative_minimum_charge_state + self.relative_maximum_charge_state: Numeric_TPS = relative_maximum_charge_state self.relative_minimum_final_charge_state = relative_minimum_final_charge_state self.relative_maximum_final_charge_state = relative_maximum_final_charge_state @@ -423,9 +424,9 @@ def __init__( self.minimal_final_charge_state = minimal_final_charge_state self.maximal_final_charge_state = maximal_final_charge_state - self.eta_charge: TemporalDataUser = eta_charge - self.eta_discharge: TemporalDataUser = eta_discharge - self.relative_loss_per_hour: TemporalDataUser = relative_loss_per_hour + self.eta_charge: Numeric_TPS = eta_charge + self.eta_discharge: Numeric_TPS = eta_discharge + self.relative_loss_per_hour: Numeric_TPS = relative_loss_per_hour self.prevent_simultaneous_charge_and_discharge = prevent_simultaneous_charge_and_discharge self.balanced = balanced @@ -663,8 +664,8 @@ def __init__( out1: Flow, in2: Flow | None = None, out2: Flow | None = None, - relative_losses: TemporalDataUser | None = None, - absolute_losses: TemporalDataUser | None = None, + relative_losses: Numeric_TPS | None = None, + absolute_losses: Numeric_TPS | None = None, on_off_parameters: OnOffParameters = None, prevent_simultaneous_flows_in_both_directions: bool = True, balanced: bool = False, @@ -916,7 +917,7 @@ def _initial_and_final_charge_state(self): ) @property - def _absolute_charge_state_bounds(self) -> tuple[TemporalData, TemporalData]: + def _absolute_charge_state_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: relative_lower_bound, relative_upper_bound = self._relative_charge_state_bounds if not isinstance(self.element.capacity_in_flow_hours, InvestParameters): return ( diff --git a/flixopt/core.py b/flixopt/core.py index 917ee2984..0d70e255b 100644 --- a/flixopt/core.py +++ b/flixopt/core.py @@ -12,16 +12,9 @@ import pandas as pd import xarray as xr -logger = logging.getLogger('flixopt') - -Scalar = int | float -"""A single number, either integer or float.""" - -PeriodicDataUser = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray -"""User data which has no time dimension. Internally converted to a Scalar or an xr.DataArray without a time dimension.""" +from .types import NumericOrBool -PeriodicData = xr.DataArray -"""Internally used datatypes for periodic data.""" +logger = logging.getLogger('flixopt') FlowSystemDimensions = Literal['time', 'period', 'scenario'] """Possible dimensions of a FlowSystem.""" @@ -150,15 +143,6 @@ def agg_weight(self): return self.aggregation_weight -TemporalDataUser = ( - int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray | TimeSeriesData -) -"""User data which might have a time dimension. Internally converted to an xr.DataArray with time dimension.""" - -TemporalData = xr.DataArray | TimeSeriesData -"""Internally used datatypes for temporal data (data with a time dimension).""" - - class DataConverter: """ Converts various data types into xarray.DataArray with specified target coordinates. @@ -405,16 +389,7 @@ def _broadcast_dataarray_to_target_specification( @classmethod def to_dataarray( cls, - data: int - | float - | bool - | np.integer - | np.floating - | np.bool_ - | np.ndarray - | pd.Series - | pd.DataFrame - | xr.DataArray, + data: NumericOrBool, coords: dict[str, pd.Index] | None = None, ) -> xr.DataArray: """ @@ -637,9 +612,3 @@ def drop_constant_arrays(ds: xr.Dataset, dim: str = 'time', drop_arrays_without_ ) return ds.drop_vars(drop_vars) - - -# Backward compatibility aliases -# TODO: Needed? -NonTemporalDataUser = PeriodicDataUser -NonTemporalData = PeriodicData diff --git a/flixopt/effects.py b/flixopt/effects.py index ddf8eadeb..02c850050 100644 --- a/flixopt/effects.py +++ b/flixopt/effects.py @@ -16,8 +16,6 @@ import numpy as np import xarray as xr -from . import io as fx_io -from .core import PeriodicDataUser, Scalar, TemporalData, TemporalDataUser from .features import ShareAllocationModel from .structure import Element, ElementContainer, ElementModel, FlowSystemModel, Submodel, register_class_for_io @@ -25,6 +23,7 @@ from collections.abc import Iterator from .flow_system import FlowSystem + from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_TPS, Scalar logger = logging.getLogger('flixopt') @@ -52,7 +51,7 @@ class Effect(Element): is_objective: If True, this effect serves as the optimization objective function. Only one effect can be marked as objective per optimization. share_from_temporal: Temporal cross-effect contributions. - Maps temporal contributions from other effects to this effect + 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. @@ -62,7 +61,6 @@ class Effect(Element): minimum_periodic: Minimum allowed total periodic contribution. maximum_periodic: Maximum allowed total periodic contribution. minimum_total: Minimum allowed total effect (temporal + periodic combined). - maximum_total: Maximum allowed total effect (temporal + periodic combined). meta_data: Used to store additional information. Not used internally but saved in results. Only use Python native types. @@ -170,16 +168,16 @@ def __init__( meta_data: dict | None = None, is_standard: bool = False, is_objective: bool = False, - share_from_temporal: TemporalEffectsUser | None = None, - share_from_periodic: PeriodicEffectsUser | None = None, - minimum_temporal: PeriodicEffectsUser | None = None, - maximum_temporal: PeriodicEffectsUser | None = None, - minimum_periodic: PeriodicEffectsUser | None = None, - maximum_periodic: PeriodicEffectsUser | None = None, - minimum_per_hour: TemporalDataUser | None = None, - maximum_per_hour: TemporalDataUser | None = None, - minimum_total: Scalar | None = None, - maximum_total: Scalar | 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, + maximum_temporal: Numeric_PS | None = None, + minimum_periodic: Numeric_PS | None = None, + maximum_periodic: Numeric_PS | None = None, + minimum_per_hour: Numeric_TPS | None = None, + maximum_per_hour: Numeric_TPS | None = None, + minimum_total: Numeric_PS | None = None, + maximum_total: Numeric_PS | None = None, **kwargs, ): super().__init__(label, meta_data=meta_data) @@ -187,8 +185,11 @@ def __init__( self.description = description self.is_standard = is_standard self.is_objective = is_objective - self.share_from_temporal: TemporalEffectsUser = share_from_temporal if share_from_temporal is not None else {} - self.share_from_periodic: PeriodicEffectsUser = share_from_periodic if share_from_periodic is not None else {} + # 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). + self.share_from_temporal = share_from_temporal if share_from_temporal is not None else {} + self.share_from_periodic = share_from_periodic if share_from_periodic is not None else {} # Handle backwards compatibility for deprecated parameters using centralized helper minimum_temporal = self._handle_deprecated_kwarg( @@ -436,18 +437,6 @@ def _do_modeling(self): ) -TemporalEffectsUser = TemporalDataUser | dict[str, TemporalDataUser] # User-specified Shares to Effects -""" This datatype is used to define a temporal share to an effect by a certain attribute. """ - -PeriodicEffectsUser = PeriodicDataUser | dict[str, PeriodicDataUser] # User-specified Shares to Effects -""" This datatype is used to define a scalar share to an effect by a certain attribute. """ - -TemporalEffects = dict[str, TemporalData] # User-specified Shares to Effects -""" This datatype is used internally to handle temporal shares to an effect. """ - -PeriodicEffects = dict[str, Scalar] -""" This datatype is used internally to handle scalar shares to an effect. """ - EffectExpr = dict[str, linopy.LinearExpression] # Used to create Shares @@ -489,9 +478,7 @@ def add_effects(self, *effects: Effect) -> None: self.add(effect) # Use the inherited add() method from ElementContainer logger.info(f'Registered new Effect: {effect.label}') - def create_effect_values_dict( - self, effect_values_user: PeriodicEffectsUser | TemporalEffectsUser - ) -> dict[str, Scalar | TemporalDataUser] | None: + def create_effect_values_dict(self, effect_values_user: Numeric_TPS | Effect_TPS | None) -> Effect_TPS | None: """Converts effect values into a dictionary. If a scalar is provided, it is associated with a default effect type. Examples: @@ -851,8 +838,3 @@ def tuples_to_adjacency_list(edges: list[tuple[str, str]]) -> dict[str, list[str graph[target] = [] return graph - - -# Backward compatibility aliases -NonTemporalEffectsUser = PeriodicEffectsUser -NonTemporalEffects = PeriodicEffects diff --git a/flixopt/elements.py b/flixopt/elements.py index 337f34fce..224cc0f9c 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -13,7 +13,7 @@ from . import io as fx_io from .config import CONFIG -from .core import PlausibilityError, Scalar, TemporalData, TemporalDataUser +from .core import PlausibilityError from .features import InvestmentModel, OnOffModel from .interface import InvestParameters, OnOffParameters from .modeling import BoundingPatterns, ModelingPrimitives, ModelingUtilitiesAbstract @@ -22,8 +22,19 @@ if TYPE_CHECKING: import linopy - from .effects import TemporalEffectsUser from .flow_system import FlowSystem + from .types import ( + Bool_PS, + Bool_S, + Bool_TPS, + Effect_PS, + Effect_S, + Effect_TPS, + Numeric_PS, + Numeric_S, + Numeric_TPS, + Scalar, + ) logger = logging.getLogger('flixopt') @@ -228,7 +239,7 @@ class Bus(Element): def __init__( self, label: str, - excess_penalty_per_flow_hour: TemporalDataUser | None = 1e5, + excess_penalty_per_flow_hour: Numeric_TPS | None = 1e5, meta_data: dict | None = None, ): super().__init__(label, meta_data=meta_data) @@ -419,16 +430,16 @@ def __init__( self, label: str, bus: str, - size: Scalar | InvestParameters = None, - fixed_relative_profile: TemporalDataUser | None = None, - relative_minimum: TemporalDataUser = 0, - relative_maximum: TemporalDataUser = 1, - effects_per_flow_hour: TemporalEffectsUser | None = None, + size: Numeric_PS | InvestParameters = None, + fixed_relative_profile: Numeric_TPS | None = None, + relative_minimum: Numeric_TPS = 0, + 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: Scalar | None = None, - flow_hours_total_min: Scalar | None = None, - load_factor_min: Scalar | None = None, - load_factor_max: Scalar | None = None, + flow_hours_total_max: Numeric_PS | None = None, + flow_hours_total_min: Numeric_PS | 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, ): @@ -716,13 +727,13 @@ def _create_bounds_for_load_factor(self): ) @property - def relative_flow_rate_bounds(self) -> tuple[TemporalData, TemporalData]: + def relative_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: if self.element.fixed_relative_profile is not None: return self.element.fixed_relative_profile, self.element.fixed_relative_profile return self.element.relative_minimum, self.element.relative_maximum @property - def absolute_flow_rate_bounds(self) -> tuple[TemporalData, TemporalData]: + def absolute_flow_rate_bounds(self) -> tuple[xr.DataArray, xr.DataArray]: """ Returns the absolute bounds the flow_rate can reach. Further constraining might be needed @@ -765,7 +776,7 @@ def investment(self) -> InvestmentModel | None: return self.submodels['investment'] @property - def previous_states(self) -> TemporalData | None: + def previous_states(self) -> xr.DataArray | None: """Previous states of the flow rate""" # TODO: This would be nicer to handle in the Flow itself, and allow DataArrays as well. previous_flow_rate = self.element.previous_flow_rate diff --git a/flixopt/features.py b/flixopt/features.py index 0d1fc7784..519693885 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -15,8 +15,9 @@ from .structure import FlowSystemModel, Submodel if TYPE_CHECKING: - from .core import FlowSystemDimensions, Scalar, TemporalData + from .core import FlowSystemDimensions from .interface import InvestParameters, OnOffParameters, Piecewise + from .types import Numeric_PS, Numeric_TPS logger = logging.getLogger('flixopt') @@ -153,7 +154,7 @@ def __init__( label_of_element: str, parameters: OnOffParameters, on_variable: linopy.Variable, - previous_states: TemporalData | None, + previous_states: Numeric_TPS | None, label_of_model: str | None = None, ): """ @@ -517,10 +518,10 @@ def __init__( dims: list[FlowSystemDimensions], label_of_element: str | None = None, label_of_model: str | None = None, - total_max: Scalar | None = None, - total_min: Scalar | None = None, - max_per_hour: TemporalData | None = None, - min_per_hour: TemporalData | None = None, + total_max: Numeric_PS | None = None, + total_min: Numeric_PS | None = None, + max_per_hour: Numeric_TPS | None = None, + min_per_hour: Numeric_TPS | None = None, ): if 'time' not in dims and (max_per_hour is not None or min_per_hour is not None): raise ValueError('Both max_per_hour and min_per_hour cannot be used when has_time_dim is False') @@ -535,18 +536,18 @@ def __init__( self._eq_total: linopy.Constraint | None = None # Parameters - self._total_max = total_max if total_max is not None else np.inf - self._total_min = total_min if total_min is not None else -np.inf - self._max_per_hour = max_per_hour if max_per_hour is not None else np.inf - self._min_per_hour = min_per_hour if min_per_hour is not None else -np.inf + self._total_max = total_max + self._total_min = total_min + self._max_per_hour = max_per_hour + self._min_per_hour = min_per_hour super().__init__(model, label_of_element=label_of_element, label_of_model=label_of_model) def _do_modeling(self): super()._do_modeling() self.total = self.add_variables( - lower=self._total_min, - upper=self._total_max, + lower=self._total_min if self._total_min is not None else -np.inf, + upper=self._total_max if self._total_max is not None else np.inf, coords=self._model.get_coords([dim for dim in self._dims if dim != 'time']), name=self.label_full, short_name='total', diff --git a/flixopt/flow_system.py b/flixopt/flow_system.py index 1fc280226..081359076 100644 --- a/flixopt/flow_system.py +++ b/flixopt/flow_system.py @@ -20,20 +20,9 @@ ConversionError, DataConverter, FlowSystemDimensions, - PeriodicData, - PeriodicDataUser, - TemporalData, - TemporalDataUser, TimeSeriesData, ) -from .effects import ( - Effect, - EffectCollection, - PeriodicEffects, - PeriodicEffectsUser, - TemporalEffects, - TemporalEffectsUser, -) +from .effects import Effect, EffectCollection from .elements import Bus, Component, Flow from .structure import CompositeContainerMixin, Element, ElementContainer, FlowSystemModel, Interface @@ -43,6 +32,8 @@ import pyvis + from .types import Bool_TPS, Effect_TPS, Numeric_PS, Numeric_TPS, NumericOrBool + logger = logging.getLogger('flixopt') @@ -168,7 +159,7 @@ 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: PeriodicDataUser | None = None, + weights: Numeric_PS | None = None, scenario_independent_sizes: bool | list[str] = True, scenario_independent_flow_rates: bool | list[str] = False, ): @@ -532,15 +523,15 @@ def to_json(self, path: str | pathlib.Path): def fit_to_model_coords( self, name: str, - data: TemporalDataUser | PeriodicDataUser | None, + data: NumericOrBool | None, dims: Collection[FlowSystemDimensions] | None = None, - ) -> TemporalData | PeriodicData | None: + ) -> xr.DataArray | None: """ Fit data to model coordinate system (currently time, but extensible). Args: name: Name of the data - data: Data to fit to model coordinates + data: Data to fit to model coordinates (accepts any dimensionality including scalars) dims: Collection of dimension names to use for fitting. If None, all dimensions are used. Returns: @@ -572,11 +563,11 @@ def fit_to_model_coords( def fit_effects_to_model_coords( self, label_prefix: str | None, - effect_values: TemporalEffectsUser | PeriodicEffectsUser | None, + effect_values: Effect_TPS | Numeric_TPS | None, label_suffix: str | None = None, dims: Collection[FlowSystemDimensions] | None = None, delimiter: str = '|', - ) -> TemporalEffects | PeriodicEffects | None: + ) -> Effect_TPS | None: """ Transform EffectValues from the user to Internal Datatypes aligned with model coordinates. """ diff --git a/flixopt/interface.py b/flixopt/interface.py index 21cbc82b9..e22ceebd5 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -19,9 +19,8 @@ if TYPE_CHECKING: # for type checking and preventing circular imports from collections.abc import Iterator - from .core import PeriodicData, PeriodicDataUser, Scalar, TemporalDataUser - from .effects import PeriodicEffectsUser, TemporalEffectsUser from .flow_system import FlowSystem + from .types import Effect_PS, Effect_TPS, Numeric_PS, Numeric_TPS logger = logging.getLogger('flixopt') @@ -73,7 +72,7 @@ class Piece(Interface): """ - def __init__(self, start: TemporalDataUser, end: TemporalDataUser): + def __init__(self, start: Numeric_TPS, end: Numeric_TPS): self.start = start self.end = end self.has_time_dim = False @@ -874,15 +873,15 @@ class InvestParameters(Interface): def __init__( self, - fixed_size: PeriodicDataUser | None = None, - minimum_size: PeriodicDataUser | None = None, - maximum_size: PeriodicDataUser | None = None, + fixed_size: Numeric_PS | None = None, + minimum_size: Numeric_PS | None = None, + maximum_size: Numeric_PS | None = None, mandatory: bool = False, - effects_of_investment: PeriodicEffectsUser | None = None, - effects_of_investment_per_size: PeriodicEffectsUser | None = None, - effects_of_retirement: PeriodicEffectsUser | None = None, + effects_of_investment: Effect_PS | Numeric_PS | None = None, + effects_of_investment_per_size: Effect_PS | Numeric_PS | None = None, + effects_of_retirement: Effect_PS | Numeric_PS | None = None, piecewise_effects_of_investment: PiecewiseEffects | None = None, - linked_periods: PeriodicDataUser | tuple[int, int] | None = None, + linked_periods: Numeric_PS | tuple[int, int] | None = None, **kwargs, ): # Handle deprecated parameters using centralized helper @@ -912,15 +911,11 @@ def __init__( # Validate any remaining unexpected kwargs self._validate_kwargs(kwargs) - self.effects_of_investment: PeriodicEffectsUser = ( - effects_of_investment if effects_of_investment is not None else {} - ) - self.effects_of_retirement: PeriodicEffectsUser = ( - effects_of_retirement if effects_of_retirement is not None else {} - ) + self.effects_of_investment = effects_of_investment if effects_of_investment is not None else {} + self.effects_of_retirement = effects_of_retirement if effects_of_retirement is not None else {} self.fixed_size = fixed_size self.mandatory = mandatory - self.effects_of_investment_per_size: PeriodicEffectsUser = ( + self.effects_of_investment_per_size = ( effects_of_investment_per_size if effects_of_investment_per_size is not None else {} ) self.piecewise_effects_of_investment = piecewise_effects_of_investment @@ -1004,7 +999,7 @@ def optional(self, value: bool): self.mandatory = not value @property - def fix_effects(self) -> PeriodicEffectsUser: + def fix_effects(self) -> Effect_PS | Numeric_PS: """Deprecated property. Use effects_of_investment instead.""" warnings.warn( 'The fix_effects property is deprecated. Use effects_of_investment instead.', @@ -1014,7 +1009,7 @@ def fix_effects(self) -> PeriodicEffectsUser: return self.effects_of_investment @property - def specific_effects(self) -> PeriodicEffectsUser: + def specific_effects(self) -> Effect_PS | Numeric_PS: """Deprecated property. Use effects_of_investment_per_size instead.""" warnings.warn( 'The specific_effects property is deprecated. Use effects_of_investment_per_size instead.', @@ -1024,7 +1019,7 @@ def specific_effects(self) -> PeriodicEffectsUser: return self.effects_of_investment_per_size @property - def divest_effects(self) -> PeriodicEffectsUser: + def divest_effects(self) -> Effect_PS | Numeric_PS: """Deprecated property. Use effects_of_retirement instead.""" warnings.warn( 'The divest_effects property is deprecated. Use effects_of_retirement instead.', @@ -1044,11 +1039,11 @@ def piecewise_effects(self) -> PiecewiseEffects | None: return self.piecewise_effects_of_investment @property - def minimum_or_fixed_size(self) -> PeriodicData: + def minimum_or_fixed_size(self) -> Numeric_PS: return self.fixed_size if self.fixed_size is not None else self.minimum_size @property - def maximum_or_fixed_size(self) -> PeriodicData: + def maximum_or_fixed_size(self) -> Numeric_PS: return self.fixed_size if self.fixed_size is not None else self.maximum_size def format_for_repr(self) -> str: @@ -1268,30 +1263,26 @@ class OnOffParameters(Interface): def __init__( self, - effects_per_switch_on: TemporalEffectsUser | None = None, - effects_per_running_hour: TemporalEffectsUser | None = None, - on_hours_total_min: int | None = None, - on_hours_total_max: int | None = None, - consecutive_on_hours_min: TemporalDataUser | None = None, - consecutive_on_hours_max: TemporalDataUser | None = None, - consecutive_off_hours_min: TemporalDataUser | None = None, - consecutive_off_hours_max: TemporalDataUser | None = None, - switch_on_total_max: int | None = None, + 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, + 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, force_switch_on: bool = False, ): - self.effects_per_switch_on: TemporalEffectsUser = ( - effects_per_switch_on if effects_per_switch_on is not None else {} - ) - self.effects_per_running_hour: TemporalEffectsUser = ( - effects_per_running_hour if effects_per_running_hour is not None else {} - ) - self.on_hours_total_min: Scalar = on_hours_total_min - self.on_hours_total_max: Scalar = on_hours_total_max - self.consecutive_on_hours_min: TemporalDataUser = consecutive_on_hours_min - self.consecutive_on_hours_max: TemporalDataUser = consecutive_on_hours_max - self.consecutive_off_hours_min: TemporalDataUser = consecutive_off_hours_min - self.consecutive_off_hours_max: TemporalDataUser = consecutive_off_hours_max - self.switch_on_total_max: Scalar = switch_on_total_max + 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.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.force_switch_on: bool = force_switch_on def transform_data(self, flow_system: FlowSystem, name_prefix: str = '') -> None: diff --git a/flixopt/io.py b/flixopt/io.py index 3c53c4170..e83738d89 100644 --- a/flixopt/io.py +++ b/flixopt/io.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: import linopy + from .types import Numeric_TPS + logger = logging.getLogger('flixopt') @@ -651,7 +653,7 @@ def update(self, new_name: str | None = None, new_folder: pathlib.Path | None = def numeric_to_str_for_repr( - value: int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray, + value: Numeric_TPS, precision: int = 1, atol: float = 1e-10, ) -> str: diff --git a/flixopt/linear_converters.py b/flixopt/linear_converters.py index 47c545506..046fcbd51 100644 --- a/flixopt/linear_converters.py +++ b/flixopt/linear_converters.py @@ -10,12 +10,13 @@ import numpy as np from .components import LinearConverter -from .core import TemporalDataUser, TimeSeriesData +from .core import TimeSeriesData from .structure import register_class_for_io if TYPE_CHECKING: from .elements import Flow from .interface import OnOffParameters + from .types import Numeric_TPS logger = logging.getLogger('flixopt') @@ -76,7 +77,7 @@ class Boiler(LinearConverter): def __init__( self, label: str, - eta: TemporalDataUser, + eta: Numeric_TPS, Q_fu: Flow, Q_th: Flow, on_off_parameters: OnOffParameters | None = None, @@ -163,7 +164,7 @@ class Power2Heat(LinearConverter): def __init__( self, label: str, - eta: TemporalDataUser, + eta: Numeric_TPS, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters | None = None, @@ -250,7 +251,7 @@ class HeatPump(LinearConverter): def __init__( self, label: str, - COP: TemporalDataUser, + COP: Numeric_TPS, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters | None = None, @@ -339,7 +340,7 @@ class CoolingTower(LinearConverter): def __init__( self, label: str, - specific_electricity_demand: TemporalDataUser, + specific_electricity_demand: Numeric_TPS, P_el: Flow, Q_th: Flow, on_off_parameters: OnOffParameters | None = None, @@ -437,8 +438,8 @@ class CHP(LinearConverter): def __init__( self, label: str, - eta_th: TemporalDataUser, - eta_el: TemporalDataUser, + eta_th: Numeric_TPS, + eta_el: Numeric_TPS, Q_fu: Flow, P_el: Flow, Q_th: Flow, @@ -551,7 +552,7 @@ class HeatPumpWithSource(LinearConverter): def __init__( self, label: str, - COP: TemporalDataUser, + COP: Numeric_TPS, P_el: Flow, Q_ab: Flow, Q_th: Flow, @@ -589,11 +590,11 @@ def COP(self, value): # noqa: N802 def check_bounds( - value: TemporalDataUser, + value: Numeric_TPS, parameter_label: str, element_label: str, - lower_bound: TemporalDataUser, - upper_bound: TemporalDataUser, + lower_bound: Numeric_TPS, + upper_bound: Numeric_TPS, ) -> None: """ Check if the value is within the bounds. The bounds are exclusive. @@ -611,13 +612,19 @@ def check_bounds( lower_bound = lower_bound.data if isinstance(upper_bound, TimeSeriesData): upper_bound = upper_bound.data - if not np.all(value > lower_bound): + + # Convert to NumPy arrays to handle xr.DataArray, pd.Series, pd.DataFrame + value_arr = np.asarray(value) + lower_arr = np.asarray(lower_bound) + upper_arr = np.asarray(upper_bound) + + if not np.all(value_arr > lower_arr): logger.warning( f"'{element_label}.{parameter_label}' is equal or below the common lower bound {lower_bound}." - f' {parameter_label}.min={np.min(value)}; {parameter_label}={value}' + f' {parameter_label}.min={np.min(value_arr)}; {parameter_label}={value}' ) - if not np.all(value < upper_bound): + if not np.all(value_arr < upper_arr): logger.warning( f"'{element_label}.{parameter_label}' exceeds or matches the common upper bound {upper_bound}." - f' {parameter_label}.max={np.max(value)}; {parameter_label}={value}' + f' {parameter_label}.max={np.max(value_arr)}; {parameter_label}={value}' ) diff --git a/flixopt/modeling.py b/flixopt/modeling.py index c7f0bf314..13b4c0e3e 100644 --- a/flixopt/modeling.py +++ b/flixopt/modeling.py @@ -5,7 +5,6 @@ import xarray as xr from .config import CONFIG -from .core import TemporalData from .structure import Submodel logger = logging.getLogger('flixopt') @@ -119,7 +118,7 @@ def count_consecutive_states( class ModelingUtilities: @staticmethod def compute_consecutive_hours_in_state( - binary_values: TemporalData, + binary_values: xr.DataArray, hours_per_timestep: int | float, epsilon: float = None, ) -> float: @@ -203,7 +202,7 @@ def expression_tracking_variable( tracked_expression, name: str = None, short_name: str = None, - bounds: tuple[TemporalData, TemporalData] = None, + bounds: tuple[xr.DataArray, xr.DataArray] = None, coords: str | list[str] | None = None, ) -> tuple[linopy.Variable, linopy.Constraint]: """ @@ -242,11 +241,11 @@ def consecutive_duration_tracking( state_variable: linopy.Variable, name: str = None, short_name: str = None, - minimum_duration: TemporalData | None = None, - maximum_duration: TemporalData | None = None, + minimum_duration: xr.DataArray | None = None, + maximum_duration: xr.DataArray | None = None, duration_dim: str = 'time', - duration_per_step: int | float | TemporalData = None, - previous_duration: TemporalData = 0, + duration_per_step: int | float | xr.DataArray = None, + previous_duration: xr.DataArray = 0, ) -> tuple[linopy.Variable, tuple[linopy.Constraint, linopy.Constraint, linopy.Constraint]]: """ Creates consecutive duration tracking for a binary state variable. @@ -394,7 +393,7 @@ class BoundingPatterns: def basic_bounds( model: Submodel, variable: linopy.Variable, - bounds: tuple[TemporalData, TemporalData], + bounds: tuple[xr.DataArray, xr.DataArray], name: str = None, ) -> list[linopy.constraints.Constraint]: """Create simple bounds. @@ -426,7 +425,7 @@ def basic_bounds( def bounds_with_state( model: Submodel, variable: linopy.Variable, - bounds: tuple[TemporalData, TemporalData], + bounds: tuple[xr.DataArray, xr.DataArray], variable_state: linopy.Variable, name: str = None, ) -> list[linopy.Constraint]: @@ -473,7 +472,7 @@ def scaled_bounds( model: Submodel, variable: linopy.Variable, scaling_variable: linopy.Variable, - relative_bounds: tuple[TemporalData, TemporalData], + relative_bounds: tuple[xr.DataArray, xr.DataArray], name: str = None, ) -> list[linopy.Constraint]: """Constraint a variable by scaling bounds, dependent on another variable. @@ -516,8 +515,8 @@ def scaled_bounds_with_state( model: Submodel, variable: linopy.Variable, scaling_variable: linopy.Variable, - relative_bounds: tuple[TemporalData, TemporalData], - scaling_bounds: tuple[TemporalData, TemporalData], + relative_bounds: tuple[xr.DataArray, xr.DataArray], + scaling_bounds: tuple[xr.DataArray, xr.DataArray], variable_state: linopy.Variable, name: str = None, ) -> list[linopy.Constraint]: diff --git a/flixopt/types.py b/flixopt/types.py new file mode 100644 index 000000000..924fae380 --- /dev/null +++ b/flixopt/types.py @@ -0,0 +1,112 @@ +"""Type system for dimension-aware data in flixopt. + +Type aliases use suffix notation to indicate maximum dimensions. Data can have any +subset of these dimensions (including scalars, which are broadcast to all dimensions). + +| Suffix | Dimensions | Use Case | +|--------|------------|----------| +| `_TPS` | Time, Period, Scenario | Time-varying data across all dimensions | +| `_PS` | Period, Scenario | Investment parameters (no time variation) | +| `_S` | Scenario | Scenario-specific parameters | +| (none) | Scalar only | Single numeric values | + +All dimensioned types accept: scalars (`int`, `float`), arrays (`ndarray`), +Series (`pd.Series`), DataFrames (`pd.DataFrame`), or DataArrays (`xr.DataArray`). + +Example: + ```python + from flixopt.types import Numeric_TPS, Numeric_PS, Scalar + + + def create_flow( + size: Numeric_PS = None, # Scalar, array, Series, DataFrame, or DataArray + profile: Numeric_TPS = 1.0, # Time-varying data + efficiency: Scalar = 0.95, # Scalars only + ): ... + + + # All valid: + create_flow(size=100) # Scalar broadcast + create_flow(size=np.array([100, 150])) # Period-varying + create_flow(profile=pd.DataFrame(...)) # Time + scenario + ``` + +Important: + Data can have **any subset** of specified dimensions, but **cannot have more + dimensions than the FlowSystem**. If the FlowSystem has only time dimension, + you cannot pass period or scenario data. The type hints indicate the maximum + dimensions that could be used if they exist in the FlowSystem. +""" + +from typing import TypeAlias + +import numpy as np +import pandas as pd +import xarray as xr + +# Internal base types - not exported +_Numeric: TypeAlias = int | float | np.integer | np.floating | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +_Bool: TypeAlias = bool | np.bool_ | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +_Effect: TypeAlias = dict[str, _Numeric] + +# Combined type for numeric or boolean data (no dimension information) +NumericOrBool: TypeAlias = ( + int | float | bool | np.integer | np.floating | np.bool_ | np.ndarray | pd.Series | pd.DataFrame | xr.DataArray +) +"""Numeric or boolean data without dimension metadata. For internal utilities.""" + + +# Numeric data types +Numeric_TPS: TypeAlias = _Numeric +"""Time, Period, Scenario dimensions. For time-varying data across all dimensions.""" + +Numeric_PS: TypeAlias = _Numeric +"""Period, Scenario dimensions. For investment parameters (e.g., size, costs).""" + +Numeric_S: TypeAlias = _Numeric +"""Scenario dimension. For scenario-specific parameters (e.g., discount rates).""" + + +# Boolean data types +Bool_TPS: TypeAlias = _Bool +"""Time, Period, Scenario dimensions. For time-varying binary flags/constraints.""" + +Bool_PS: TypeAlias = _Bool +"""Period, Scenario dimensions. For period-specific binary decisions.""" + +Bool_S: TypeAlias = _Bool +"""Scenario dimension. For scenario-specific binary flags.""" + + +# Effect data types +Effect_TPS: TypeAlias = _Effect +"""Time, Period, Scenario dimensions. Dict mapping effect names to values. +For time-varying effects (costs, emissions). Use `Effect_TPS | Numeric_TPS` to accept single values.""" + +Effect_PS: TypeAlias = _Effect +"""Period, Scenario dimensions. Dict mapping effect names to values. +For period-specific effects (investment costs). Use `Effect_PS | Numeric_PS` to accept single values.""" + +Effect_S: TypeAlias = _Effect +"""Scenario dimension. Dict mapping effect names to values. +For scenario-specific effects (carbon prices). Use `Effect_S | Numeric_S` to accept single values.""" + + +# Scalar type (no dimensions) +Scalar: TypeAlias = int | float | np.integer | np.floating +"""Scalar numeric values only. Not converted to DataArray (unlike dimensioned types).""" + +# Export public API +__all__ = [ + 'Numeric_TPS', + 'Numeric_PS', + 'Numeric_S', + 'Bool_TPS', + 'Bool_PS', + 'Bool_S', + 'Effect_TPS', + 'Effect_PS', + 'Effect_S', + 'Scalar', + 'NumericOrBool', +]